309 Commits

Author SHA1 Message Date
dca1d1c25b feat: add darktable web API interface with gallery, image detail, and settings screens
- Implemented main entry point for React application in `main.jsx`.
- Created `Gallery` component for displaying images with infinite scroll and pull-to-refresh functionality.
- Developed `ImageDetail` component for showing detailed information and actions for selected images.
- Added `Settings` component for configuring API connection settings.
- Integrated Vite for build and development with PWA support.
- Implemented caching strategies for API responses and image previews using Workbox.
2026-06-10 23:38:06 -04:00
0831ee943c feat(api): enhance image module management and export functionality
- Added read-write access to the darktable SQLite catalog for module parameter edits.
- Implemented new endpoints for exporting images and retrieving full-resolution images.
- Introduced PATCH method for updating module parameters with optional preview.
- Added comprehensive tests for new API functionalities, including module retrieval and error handling.
- Updated CORS headers to support new HTTP methods.
- Created detailed documentation for new API endpoints and module operations.
2026-06-10 19:50:56 -04:00
bd40b4f850 Add darktable REST API implementation
- Introduced a new Lua-based REST API for darktable, allowing external applications to interact with the darktable library.
- Implemented core functionalities including image listing, retrieval, and preview serving.
- Added support for OAuth 2.0 authentication with token generation.
- Created a simple HTTP router to handle API requests.
- Included JSON encoding/decoding utilities for API responses.
- Developed a script to launch the API server alongside darktable, ensuring seamless integration.
- Added necessary configuration and setup for the API server, including CORS support and error handling.
2026-06-10 15:02:19 -04:00
wpferguson
5ebadd83ea Merge pull request #669 from wpferguson/sm_logging
tools/script_manager - changed log msg from debug to info
2026-04-19 16:00:05 -04:00
Bill Ferguson
01c3b7d2f6 tools/script_manager - changed log msg from debug to info 2026-04-19 15:58:35 -04:00
wpferguson
bfee6b12dc Merge pull request #668 from wpferguson/script_manager_fix
tools/script_manager - fixed gui safety test for gui installation
2026-04-19 14:56:04 -04:00
Bill Ferguson
503a59d590 tools/script_manager - fixed gui safety test for gui installation 2026-04-19 14:54:25 -04:00
wpferguson
78176e32e8 Merge pull request #631 from wpferguson/bundled_script_manager
Updated script_manager for bundled scripts

Part of https://github.com/darktable-org/darktable/pull/19899
2026-04-19 14:38:24 -04:00
wpferguson
4d8d076d8e Merge pull request #667 from bkhl/master
Missing argument in group_persistence.lua
2026-04-18 15:29:10 -04:00
Björn Lindström
72f31d6208 Missing argument in group_persistence.lua
`add_to_group` was still failing as the leader was not provided
as second argument.
2026-04-18 20:59:58 +02:00
wpferguson
784a6ea85d Merge pull request #666 from bkhl/group_persistence-function-order
Order functions in dependency order in group_persistance.lua
2026-04-18 14:49:59 -04:00
Björn Lindström
bbfd9d12e3 Order functions in dependency order in group_persistance.lua
The call to `add_to_group` in `build_group` failed as `add_to_group`
was a local function defined later. This reorders the functions to avoid
that.
2026-04-18 20:38:52 +02:00
wpferguson
a55de740e5 Merge pull request #663 from deekayhd/issue_661
fix incomplete removal of datetime_taken in PR #653
2026-04-11 11:11:20 -04:00
deekayhd
dc31b221ef fix username as nil 2026-04-11 14:36:30 +02:00
deekayhd
34b88490c2 fix assignment of empty variables derived from exit_datetime_taken 2026-04-11 14:30:29 +02:00
deekayhd
d9b77bb3a2 fix incomplete removal of datetime_taken in PR #653 2026-04-10 19:56:00 +02:00
wpferguson
cc198d6ca1 Merge pull request #662 from wpferguson/fix_transfer_hierachy
contrib/transfer_hierarchy bug fix
2026-04-09 12:21:31 -04:00
Bill Ferguson
84582cbf29 contrib/transfer_hierarchy - fixed unused variable being used as
boolean function
2026-04-09 12:18:54 -04:00
wpferguson
9e7a18e290 Merge pull request #660 from wpferguson/readd_toggle_group_view
re-add contrib/toggle_group_view
2026-04-06 21:23:41 -04:00
Bill Ferguson
8ff81fcfa4 contrib/toggle_group_view - re-added toggle_group_view after getting
button sensitivity figured out.

This script adds a way to toggle a selected or hovered group to it's
own collection and back again
2026-04-06 21:20:32 -04:00
wpferguson
b649e7a840 Merge pull request #659 from wpferguson/fix_siv_typo
contrib/selected_image_visible - corrected typo
2026-04-05 15:32:32 -04:00
Bill Ferguson
efa06ee34c contrib/selected_image_visible - corrected typo 2026-04-05 15:31:38 -04:00
wpferguson
77959422e0 Merge pull request #658 from wpferguson/remove_toggle_group_view
contrib/toggle_group_view - removed because there are too many problems
2026-04-04 20:10:29 -04:00
Bill Ferguson
0bff346b05 contrib/toggle_group_view - removed because there are too many problems
needing fixed.
2026-04-04 20:08:14 -04:00
wpferguson
d05c494147 Merge pull request #657 from wpferguson/fix_event_destroy
contrib/toggle_group_view - fixed event destroy call
2026-04-04 19:53:27 -04:00
Bill Ferguson
9cbbae9621 contrib/toggle_group_view - fixed event destroy call 2026-04-04 19:52:13 -04:00
wpferguson
fb2aade198 Merge pull request #656 from wpferguson/fix_button_function_call
contrib/selected_image_visible - corrected action button function call
2026-04-04 19:48:07 -04:00
Bill Ferguson
7ffd8bdc27 contrib/selected_image_visible - corrected action button function call 2026-04-04 19:44:53 -04:00
wpferguson
b689dbc7ee Merge pull request #655 from wpferguson/update_toogle_group_view
contrib/toggle_group_view
2026-04-04 17:20:49 -04:00
Bill Ferguson
027135a257 contrib/toggle_group_view - updated to current version from old
version I mistakenly used.
2026-04-04 17:19:23 -04:00
wpferguson
643057deaf Merge pull request #652 from wpferguson/selected_image_visible
contrib/selected_image_visible
2026-04-04 15:56:12 -04:00
Bill Ferguson
a00f71f533 contrib/selected_image_visible - Added action button to make
selected non visible image visible
2026-04-04 15:54:00 -04:00
wpferguson
36d6db24e7 Merge pull request #654 from wpferguson/fix_s2c_destroy
official/selection2collection - fixed 2 bugs with destroy function
2026-04-04 15:23:26 -04:00
Bill Ferguson
f62a5a817f official/selection2collection - fixed 2 bugs with destroy function 2026-04-04 15:21:41 -04:00
wpferguson
44b40b305b Merge pull request #651 from wpferguson/select_raw_non_raw
select_raw_non_raw
2026-04-04 15:01:46 -04:00
wpferguson
97dade06cf Merge pull request #650 from wpferguson/group_persistence
official/group_persistence
2026-04-04 15:01:25 -04:00
wpferguson
30167631e7 Merge pull request #649 from wpferguson/toggle_group_view
toggle_group_view
2026-04-04 15:01:01 -04:00
wpferguson
8235de1f0a Merge pull request #648 from wpferguson/select_unaltered
select_unaltered
2026-04-04 15:00:26 -04:00
wpferguson
d67ada8300 Merge pull request #646 from wpferguson/recent_bookmarks
recent_bookmarks
2026-04-04 15:00:07 -04:00
wpferguson
c6788e4370 Merge pull request #645 from wpferguson/use_paired_jpg_as_mipmap
use_paired_jpg_as_mipmap
2026-04-04 14:59:46 -04:00
wpferguson
d238fcee61 Merge pull request #644 from wpferguson/selection2collection
selection2collection
2026-04-04 14:58:52 -04:00
wpferguson
28d71c989f Merge pull request #643 from wpferguson/extract_burst_roll_images
extract_burst_roll_images
2026-04-04 14:58:16 -04:00
wpferguson
74f020c809 Merge pull request #642 from wpferguson/auto_straighten
auto_straighten
2026-04-04 14:57:43 -04:00
wpferguson
80769a753a Merge pull request #641 from wpferguson/select_duplicates
select duplicates
2026-04-04 14:57:14 -04:00
wpferguson
427359769e Merge pull request #647 from wpferguson/scheduler
scheduler
2026-04-04 14:56:17 -04:00
Bill Ferguson
5e5b073cec official/use_paired_jpg_as_mipmap - fixed typo in namespace table 2026-03-30 21:12:51 -04:00
Bill Ferguson
c771c5fc1b official/use_paired_jpg_as_mipmap - changed isc message to a broadcast
so that any cache building script could catch it and then execute.
2026-03-30 20:51:37 -04:00
Bill Ferguson
7cdf1d4c7f official/use_paired_jpg_as_mipmap - Added more logging and changed
trigger message for cache building scripts
2026-03-30 13:19:41 -04:00
wpferguson
b6ea6211db Merge pull request #653 from deekayhd/no_exif_date_corr
fix wrong month in EXIF.MONTH.LONG and EXIF_MONTH.SHORT when no exif date available in image

Thanks
2026-03-30 13:00:31 -04:00
deekayhd
4d3924939b remove unnecessary variable datetime_taken 2026-03-29 23:45:40 +02:00
deekayhd
80ff1ef0aa add warning message 2026-03-29 23:37:22 +02:00
deekayhd
af362315e8 set all vars derived from datetime_taken to "" 2026-03-29 23:22:04 +02:00
deekayhd
28f3e9f6a2 fix wrong month in EXIF.MONTH.LONG and EXIF_MONTH.SHORT when no date available 2026-03-29 14:29:39 +02:00
Bill Ferguson
4f05672809 contrib/selected_image_visible - add script that creates a shortcut
to make the selected image visible in lighttable.
2026-03-26 23:34:50 -04:00
Bill Ferguson
8d8f295c77 official/select_raw_non_raw - updated help url 2026-03-26 23:17:25 -04:00
Bill Ferguson
a81ab437cf official/select_raw_non_raw - added script that creates two selection
buttons, one to select raw images and the other to select non raw
images
2026-03-26 22:43:14 -04:00
Bill Ferguson
a94a0c2c90 contrib/toggle_group_view - updated help link 2026-03-26 22:41:28 -04:00
Bill Ferguson
0eda1b6ac5 official/group_persistence - add script to store group information in
xmp files so that groups can be maintained across database instances
2026-03-26 22:06:33 -04:00
Bill Ferguson
58bc3a69df contrib/toggle_group_view - moved from official to contrib to handle
event conflict with official/group_persistence.  Moving to contrib forces
toggle_group_view to load before group_persistence and thus get events that
both need, first.  If the order is reversed then toggle_group_view can bog
down in a loop making darktable unresponsive.

Fixed bad call in destroy and added check for selection-changed to make sure
there are images before trying to set sensitivity.
2026-03-26 13:57:04 -04:00
Bill Ferguson
35398c55e5 official/toggle_group_view - fix no images selected so can't get a count of nil 2026-03-25 13:07:03 -04:00
Bill Ferguson
1762b372b1 official/toggle_group_view - made button sensitive to group members
selected
2026-03-25 12:33:21 -04:00
Bill Ferguson
1907f9a1f5 official/toggle_group_view - added a script to view a group as
a collection when toggled from lighttable
2026-03-24 22:57:45 -04:00
Bill Ferguson
b1dd3eca5a official/select_unaltered - add select button to select images that
haven't been altered in any way
2026-03-24 22:18:46 -04:00
Bill Ferguson
732d2788ca official/scheduler - script to provide a FIFO scheduler for cooperative
Lua multitasking.
2026-03-24 21:38:20 -04:00
Bill Ferguson
04908e3f0b official/recent_bookmarks - Update the recently-used.xbel file showing
recently used files for images edited in or exported from darktable.

Current this only works on Linux
2026-03-24 21:01:20 -04:00
Bill Ferguson
3ba3facad2 official/use_paired_jpg_as_mipmap - Canon R series cameras don't
included a full size embedded jpeg so generating mipmap cache for
anything larger than mipmap 3 requires the raw to be loaded and
processed.  This script copies the paired jpeg from the RAW+JPEG
pair to the full resolution mipmap.  This can then be used to generate
the remaining mipmap cache.  The jpeg image can be discarded after
being copied to the cache or retained based on a preference in Lua
options.
2026-03-24 20:45:44 -04:00
Bill Ferguson
cb209d1d56 official/selection2collection - create a temporary collection from
a selection.  Temporary collections are removed when darktable exits.
Temporary collections can be kept by setting a preference in the Lua options.
2026-03-24 20:29:35 -04:00
Bill Ferguson
36533b1004 official/extract_burst_roll_images - script to extract Canon burst
roll images from a burst roll image container
2026-03-24 20:07:48 -04:00
Bill Ferguson
cbbf0f413f official/auto_straighten - add script to straighten image based on
roll and pitch metadata
2026-03-24 19:56:13 -04:00
Bill Ferguson
181aea5bc2 official/select_duplicates - Added script to detect and select duplicates.
Script adds a button to the select images module.
2026-03-24 17:10:08 -04:00
wpferguson
94c6dff245 Merge pull request #636 from deekayhd/rename_images_reset
enable reset in rename_images.lua
2026-03-21 10:48:09 -04:00
Bill Ferguson
79ac6fc31e tools/script_manager - added check for darktable_gui_safe to avoid
startup race condition hang
2026-03-20 16:08:20 -04:00
deekayhd
7a61d77c27 add reset of preference 2026-03-19 11:39:36 +01:00
deekayhd
2635e261a0 changes after review 2026-03-19 11:38:45 +01:00
wpferguson
d197851478 Merge pull request #637 from wpferguson/image_time_filename_sanitize
contrib/image_time - sanitize filenames so that spaces don't cause problems
2026-03-18 10:50:28 -04:00
Bill Ferguson
6f519b3ce8 contrib/image_time - sanitize filenames so that spaces don't cause
problems
2026-03-18 10:46:12 -04:00
deekayhd
03eb79e83a enable reset in rename_images.lua 2026-03-13 21:55:14 +01:00
wpferguson
fb03b49a93 Merge pull request #633 from lefth/photils-cli-path
Fix the path to photils-cli on mac

Thanks
2026-02-15 13:17:46 -05:00
Dan Zwell
c0865c37bb Fix the path to photils-cli on mac 2026-02-15 17:15:32 +08:00
Bill Ferguson
6bbb619513 tools/script_manager - Changed to allow running scripts included
with the darktable distribution.  User installed
                       scripts will override the bundled scripts
2026-02-02 19:29:37 -05:00
wpferguson
182a2c96c3 Merge pull request #630 from wpferguson/select_non_existing_speedup
Speed up select_non_existing
2026-01-28 21:22:46 -05:00
Bill Ferguson
06522802d8 contrib/select_non_existing - changed the logic to get a list of
files in the filmroll and check the
                              "known" files against the file list.
                              This cuts the number of system calls
                              from 1 per image to 1 per film roll
                              resulting an incredible speed up on
                              windows and a good speedup on other
                              platforms.
2026-01-28 21:14:39 -05:00
wpferguson
ad263fed11 Merge pull request #626 from ipalopezhentsev/patch-1
reorder lines
2026-01-04 14:17:42 -05:00
Ilya Palopezhentsev
ced60016fb reorder lines
oops, committed line before the line it should go after
2026-01-04 20:12:35 +01:00
wpferguson
1d6d0695fe Merge pull request #625 from ipalopezhentsev/patch-2
Fix enfuseAdvanced command line building bugs
2026-01-04 13:27:22 -05:00
wpferguson
fbe504b47a Merge pull request #624 from ipalopezhentsev/patch-1
fix dtutils_system.external_command ignoring bad return code on Windows
2026-01-04 13:26:52 -05:00
Ilya Palopezhentsev
7da38b3665 Fix enfuseAdvanced command line building bugs
1) enfuse has argument --contrast-min-curvature taking string with a percent sign. When that runs on Windows, it fails due to the fact that % means env var substitution in Windows command lines. we have to escape it with double percent
2) when enfuse produces a jpg, it takes argument --compression with an integer value. But the script takes it from GUI and passes as a float which breaks enfuse. 
3) When errors like these happen, the script forgets to stop darktable job leaving dangling string in UI.
2026-01-04 12:17:35 +01:00
Ilya Palopezhentsev
08fe1ba410 fix dtutils_system.external_command ignoring bad return code on Windows
On Windows, external_command calls windows_command which prepares a bat file with the command, surrounded with code page saving/restoration. If the "real" command fails, its error return code does not get anywhere and 0 is always returned, because the last line is a chcp call which always succeeds.
This breaks error logic in scripts, e.g. enfuseAdvanced, where they think everything went fine when it didn't.
2026-01-04 10:20:03 +01:00
wpferguson
088c79e140 Merge pull request #622 from dterrahe/patch-1
Update author name in x-touch.lua metadata
2026-01-01 11:47:19 -05:00
dterrahe
f06eebe316 Update author name in x-touch.lua metadata 2025-12-26 11:29:35 +01:00
wpferguson
d6a1cec9fa Merge pull request #620 from wpferguson/revert_ext_editors_sanitize
reverted export2collection sanitize for new file name
2025-12-20 19:53:12 -05:00
Bill Ferguson
e194ad2bc7 reverted export2collection sanitizing new file name as it causes a
problem on windows.
2025-12-20 19:50:24 -05:00
wpferguson
22e3c59d69 Merge pull request #611 from scorpi11/website_gallery
Website gallery replacement
2025-12-18 12:18:44 -05:00
Tino Mettler
eb743e4b3b Make some further strings transtlatable 2025-12-18 18:11:04 +01:00
Tino Mettler
7a8f3afc40 Portability: use system specific path separator, sanitize filenames 2025-12-18 17:23:00 +01:00
Tino Mettler
2b3910c698 CSS style cleanup 2025-12-18 08:19:14 +01:00
Tino Mettler
c584ee05c9 Remove unused JavaScript code and outdated comments 2025-12-18 08:02:43 +01:00
Tino Mettler
bb3a74feac CSS cleanup, separate functional and visual CSS code
Added file style.css which is aimed to contain CSS code that affects
the look of the gallery.

This could be extended to provide different or user-modified styles
to give each gallery an individual look.
2025-12-18 07:55:53 +01:00
wpferguson
90d2764a5a Merge pull request #604 from MStraeten/patch-3
Refactor x-touch script, basic AgX support
2025-12-10 20:39:29 -05:00
Tino Mettler
b0373f7166 Use subdirectories in the generated gallery
JavaScript files are now in js/, CSS files in css/, thumbnail images in
thumbnails/.
2025-12-10 08:46:24 +01:00
Tino Mettler
c6002e7eb7 Make UI text elements translatable 2025-12-10 08:46:11 +01:00
Tino Mettler
a47de82afd Show progress during thumbnail export 2025-12-08 14:53:58 +01:00
wpferguson
7f09e55525 Merge pull request #616 from wpferguson/fix_multi_os
examples/multi_os - replaced ufraw-batch with exiftool
2025-12-07 14:18:29 -05:00
wpferguson
f83ac1366a Merge pull request #617 from wpferguson/ext_editor_sanitize
ext_editor sanitize file name
2025-12-07 14:18:11 -05:00
Bill Ferguson
6cda27172e contrib/ext_editor - sanitize filename before importing to collection 2025-12-06 19:11:12 -05:00
Bill Ferguson
df78522a79 examples/multi_os - replaced ufraw-batch with exiftool to extract
the embedded jpeg or preview image.
2025-12-06 18:40:37 -05:00
wpferguson
6e64d6db6f Merge pull request #613 from wpferguson/string_lib_fixes
String lib fixes
2025-12-04 15:13:56 -05:00
Tino Mettler
7f00cb8d6c Update TODO list for the website gallery export 2025-12-04 19:09:22 +01:00
Tino Mettler
dfeff73fcd Add callback to check if export format is supported
Currently JPG, TIFF, PNG and WebP are supported.
2025-12-04 19:06:26 +01:00
Bill Ferguson
d75f00daa0 lib/dtutils/string - fixed ROLL.NAME to support the original and
the ROLL.NAME[n] variant in the same variable
                     string
2025-12-03 20:54:47 -05:00
Bill Ferguson
3e430ee2be lib/dtutils/string - converted element from string to number for use in test 2025-12-03 18:26:13 -05:00
Bill Ferguson
3856ae3449 lib/dtutils/string - added format_exposure function to take an
exif_exposure and return it as an exposure
                     string (exif_exposure = 500, string 1/500)
2025-12-02 20:40:03 -05:00
Bill Ferguson
d54a9f8b7e lib/dtutils/string - implemented variable substitution for ROLL.NAME[n] 2025-12-02 19:54:10 -05:00
Bill Ferguson
8f6cc850fb lib/dtutils/string - Added Xmp.darktable.version and Xmp.dc.[title,
description,publisher,rights,creator]
2025-12-02 18:57:05 -05:00
Bill Ferguson
e9f18a3d36 lib/dtutils/string - add IMAGE.ID and IMAGE.ID.NEXT variables 2025-12-02 18:34:18 -05:00
wpferguson
f3a89a89bd Merge pull request #612 from wpferguson/fix_apply_camera_style
official/apply_camera_style - fixed pattern matching bugs
2025-12-02 16:30:56 -05:00
Bill Ferguson
d075fb7128 official/apply_camera_style - fixed pattern matching bug that allowed
camera styles to be applied twice

                              fixed pattern matching bug affecting
                              Canon EOS R, RP, and R5

                              Added more debugging statements to
                              pattern matching functions
2025-12-02 16:23:28 -05:00
Tino Mettler
7f4bae4234 Update TODO 2025-11-26 22:31:10 +01:00
Tino Mettler
071a3527c1 Add new website gallery module to README.md 2025-11-26 21:32:19 +01:00
Tino Mettler
22c9a7cd6e Comments, remove cruft 2025-11-26 21:32:10 +01:00
Tino Mettler
12ccefcc3b Add missing copyright/license information 2025-11-26 21:31:41 +01:00
Tino Mettler
fa6014fefa Export module for a website gallery with selected images
This export module should replace the PhotoSwipe copy in darktable.

See https://github.com/darktable-org/darktable/issues/16205 for more
details.
2025-11-26 20:46:11 +01:00
wpferguson
e831f377bf Merge pull request #610 from wpferguson/fix_selection_to_pdf_open
official/selection_to_pdf
2025-11-23 21:09:02 -05:00
Bill Ferguson
d696e6e3f2 official/selection_to_pdf - added default opens for Linux, MacOS,
and Windows
2025-11-23 21:04:54 -05:00
wpferguson
db505f1e0a Merge pull request #608 from wpferguson/fix_metadata_string_substitution
lib/dtutils/string - image metadata may return a nil so check to
2025-11-14 20:04:29 -05:00
Bill Ferguson
57d15a3fa6 lib/dtutils/string - image metadata may return a nil so check to
see if there is a value and return it otherwise
                     return an empty string
2025-11-11 21:59:07 -05:00
Martin Straeten
3c131c41d1 adaption to recent agx update
renamed action and reordered sliders
2025-11-11 22:00:25 +01:00
wpferguson
4d54b5eaba Merge pull request #606 from wpferguson/add_official_regenerate_thumbnails
official/regenerate_thumbnails - add a script to fix skull thumbnails
2025-11-09 22:56:05 -05:00
Martin Straeten
ffa125b134 further improved customizability
to add a new module, simply add an entry to MODULE_PROFILES below.
direct usage of action paths from darktable's shortcut interface.
2025-11-09 20:15:34 +01:00
Bill Ferguson
dbf0c5d853 official/regenerate_thumbnails - added check to ensure job has not
been canceled.
2025-11-09 00:13:48 -05:00
Bill Ferguson
193a615772 official/regenerate_thumbnails - add a script to fix skull thumbnails
by dropping the cache for the image
                                 and regenerating it.  Adds a button
                                 to the actions on selected images
                                 module.  Also has a shortcut to
                                 apply the script by hovering and
                                 triggering the shortcut.
2025-11-08 21:27:14 -05:00
Martin Straeten
056f616283 Refactor x-touch script, basic AgX support
Refactored x-touch script to improve structure and maintainability. Mainly by adding helper functions for handling different module types.
the added support for AgX module
(credit goes to claude.ai which helped a lot )
2025-11-06 19:19:46 +01:00
wpferguson
447980ddc4 Merge pull request #603 from wpferguson/fix_stringlib_sequence_arguments
Fix sequence string substitution variable to accept starting number > 9
2025-11-02 17:05:22 -05:00
Bill Ferguson
8af5476f8d lib/dtutils/string - fixed string substitution sequence to accept
start argument of more than 1 digit
2025-11-02 16:59:32 -05:00
wpferguson
c95547caa7 Merge pull request #584 from balping/patch-1
Add manual coordinate input option to geoToolbox
2025-08-18 17:01:22 -04:00
wpferguson
b4d3b2901f Merge pull request #585 from mreiger/master
Add a script to automatically tag manually modified images
2025-08-18 16:57:00 -04:00
wpferguson
cb6b3161d8 Merge pull request #595 from sjjh/patch-1
Update README.md: added link to script_manager docs
2025-08-18 16:55:27 -04:00
wpferguson
2d82eea237 Merge pull request #594 from deekayhd/master
rename_images: error when renaming images without exif data
2025-08-18 16:53:28 -04:00
Michael Reiger
3beee93af4 Merge branch 'darktable-org:master' into master 2025-08-01 07:36:10 +00:00
sjjh
68291414f3 Update README.md: added link to script_manager docs
just for convenience: added a link to dt documenation for script manager module in the 'enable' section
2025-07-13 16:56:22 +02:00
deekayhd
5662519314 Merge pull request #1 from deekayhd/no_exif_data
rename_images: error with images without exif data
2025-07-09 21:15:42 +02:00
deekayhd
28ab065293 Merge branch 'darktable-org:master' into no_exif_data 2025-07-09 20:52:02 +02:00
wpferguson
aed3275943 Merge pull request #593 from wpferguson/592_fix_RL_out_sharp
fix RL_out_sharp crash
2025-07-05 12:43:12 -04:00
Bill Ferguson
d7aeb43340 contrib/RL_out_sharp - changed MODULE to MODULE_NAME so that message
would print and script wouldn't crash
2025-07-05 12:39:36 -04:00
deekayhd
ea79d10b11 Update string.lua
initialize datetime_taken with proper value if no exif_datetime_taken can be found (e.g. in TIFF files)
2025-07-01 13:55:47 +02:00
Michael Reiger
e4001f984f Merge branch 'darktable-org:master' into master 2025-06-26 22:13:59 +00:00
wpferguson
453d744b69 Merge pull request #588 from wpferguson/18366_fix_apply_style_camera_decoding
fix apply_camera_style style decoding
2025-06-01 17:07:36 -04:00
Bill Ferguson
b9cc0dccce official/apply_camera_style - made spaces in camera name conditional
matches as some are squashed and others
                              (Olympus) are not.
2025-06-01 16:36:52 -04:00
Michael Reiger
84fea20ee5 fixed script desription 2025-06-01 13:28:44 +02:00
Michael Reiger
e054bfc59d Added script to automatically tag manually modified images 2025-06-01 13:12:56 +02:00
Michael Reiger
8cd10dce68 Revert "Modified apply_camera_style script so that the application of a camera style does not mark the image as changed" because that attempt did not work.
This reverts commit f8d49ccd01.
2025-06-01 12:50:59 +02:00
Michael Reiger
f8d49ccd01 Modified apply_camera_style script so that the application of a camera style does not mark the image as changed 2025-05-24 19:31:23 +02:00
Balázs Dura-Kovács
25afb41924 Create tooltips for geoToolbox 2025-05-06 19:37:09 +02:00
Balázs Dura-Kovács
3001a0dc1a Add manual coordinate input option to geoToolbox 2025-05-04 23:23:33 +02:00
wpferguson
62e932990e Merge pull request #583 from wpferguson/fix_image_time_issue_582
Fix image time exiftime generation with blank fields
2025-05-01 20:40:48 -04:00
Bill Ferguson
980fe3962c image_time - added default values of 0 for fields not supplied in
set time so that an exiftime can be created.\
2025-05-01 20:37:24 -04:00
wpferguson
0043769946 Merge pull request #579 from tabac/rename-images-sequence-tooltip
Align sequence tooltip with code.
2025-04-10 20:40:31 -04:00
Tasos Bakogiannis
0cb9a8872e Update sequence variable order. 2025-04-10 19:26:45 +02:00
Tasos Bakogiannis
5309ff1726 Revert "Align sequence tooltip with code."
This reverts commit c6eee15d75.
2025-04-10 19:25:01 +02:00
Tasos Bakogiannis
c6eee15d75 Align sequence tooltip with code. 2025-04-09 23:48:48 +02:00
wpferguson
8c2d0ddf2e Merge pull request #577 from kmilos/patch-1
Fix de_DE translation charset
2025-04-02 22:24:42 -04:00
Miloš Komarčević
87d18549a6 Fix de_DE translation charset 2025-04-01 16:11:56 +02:00
wpferguson
6fdd78114f Merge pull request #576 from fjb2020/master
Fix issue #571, use of for _,image in ipairs(sel_images) do conflicts with function _(msgid)
2025-03-19 14:52:19 -04:00
fjb2020
f20721373a Fix issue #571, use of for _,image in ipairs(sel_images) do conflicts with function _(msgid) 2025-03-19 15:57:10 +00:00
wpferguson
9c0f8d63e6 Merge pull request #575 from Yurivanderburg/fix_typo_in_hugin
Fixed typo in hugin script - should resolve issue # 570

Thanks
2025-03-18 17:24:58 -04:00
Yuri van der Burg
ea2596ae13 Fixed typo in hugin script - should resolve issue # 570 2025-03-18 20:38:21 +01:00
wpferguson
a9191cf084 Merge pull request #568 from wpferguson/fix_script_manager_startup_button_states
Fix script_manager startup power button state
2025-03-08 15:52:26 -05:00
Bill Ferguson
59b5039579 tools/script_manager - Moved start_scripts() later in the script to ensure
the UI is populated before starting the scripts.  Added
                       a check to see if the script is started and if so the
                       power button is updated to show the script is running.
2025-03-08 15:46:55 -05:00
wpferguson
e070a1462c Merge pull request #553 from MStraeten/patch-2
Update x-touch.lua switch colorequal tab
2025-03-01 20:02:54 -05:00
wpferguson
faa3302a37 Merge pull request #566 from sercxanto/fix_dbmaint_log
Fix dbmaint.lua
2025-03-01 14:03:57 -05:00
wpferguson
115dbbec32 Merge pull request #563 from sercxanto/fix_version_comparison
Fix version comparison
2025-03-01 14:00:32 -05:00
Georg Lutz
9bf7efe71f Fix dbmaint.lua
"log.log_msg" triggers a lua function error, because that function does not exist.

Additionally add add set_log_level / restore_log_level to improve compatibility with other files.
2025-03-01 08:46:00 +01:00
wpferguson
3ec9dc4963 Merge pull request #565 from wpferguson/fix_autostyle_pattern_match
fix autostyle pattern match
2025-02-28 15:14:13 -05:00
Bill Ferguson
cddb86ded0 contrib/autostyle - fixed pattern match for tag result to handle spaces in the pattern 2025-02-28 15:11:15 -05:00
Georg Lutz
2624338100 Rename compare_versions to compare_api_versions 2025-02-23 18:29:34 +01:00
Georg Lutz
067fe29f76 Fix version comparison
Currently this is a textual string comparison which will break with 10.0.0 API version.
2025-02-23 12:23:37 +01:00
wpferguson
926272b1cd Merge pull request #560 from wpferguson/script_manager_delayed_start
script_manager - delay script start until all libraries are scanned
2025-02-15 20:28:26 -05:00
Bill Ferguson
363524fbaf tools/script_manager - added a start queue for scripts that start at
start up to allow for including and using libraries
                       in other than the default path.  Scripts are now
                       started after all directories are scanned instead of
                       being started as they were found.
2025-02-15 20:19:29 -05:00
wpferguson
edeceb268e Merge pull request #558 from wpferguson/fix_acs_pattern_match
Fix apply_camera_style pattern matching
2025-02-15 13:59:49 -05:00
Bill Ferguson
252cbc96f2 apply_camera_style - made spaces conditional in the pattern match
so that pattern names with spaces will match
                     models that don't have spaces
2025-02-15 13:55:08 -05:00
Bill Ferguson
b70fdc0ecf official/apply_camera_style - Fixed decoding errors caused by #18332. 2025-02-07 14:29:45 -05:00
Martin Straeten
96457bc598 Update x-touch.lua switch colorequal tab
added recommended additional shortcuts to switch between hue, saturation and brightness tabs:
midi:D0=iop/colorequal/page;previous
midi:D#0=iop/colorequal/page;next
2025-01-18 23:44:48 +01:00
wpferguson
cd31d18075 Merge pull request #552 from wpferguson/fix_windows_command_escaping
lib/dtutils/system.lua escaping fix
2025-01-17 14:22:07 -05:00
Bill Ferguson
4e140f10d1 lib/dtutils/system - fixed Lua escaping in windows_command() 2025-01-17 14:18:19 -05:00
wpferguson
593f8049a0 Merge pull request #550 from wpferguson/fix_autogrouper_use_of_group_leader
Fix AutoGrouper group mechanics
2025-01-07 13:43:13 -05:00
wpferguson
89973b3f9a Merge pull request #549 from Naahuel/master
Add change encoding to bat file for dtutils/system.lua windows_command

Thanks.
2025-01-07 13:42:54 -05:00
wpferguson
deca366ce4 Merge pull request #502 from koto/ultrahdr
Added UltraHDR export plugin to generate UltraHDR JPEG images.

Thanks
2025-01-07 13:36:22 -05:00
Bill Ferguson
914a17fb4e contrib/AutoGrouper - create group leader for first image in table.
Add grouped images to the group leader and not
                      the previous image.
2025-01-06 22:06:35 -05:00
Nahuel José
1e33ea4192 fix: add change encoding to bat file for dtutils windows_command 2025-01-06 18:47:57 -03:00
Krzysztof Kotowicz
9f3fb16161 Addressed review comments. 2025-01-04 00:49:56 +01:00
wpferguson
3f064783b7 Merge pull request #544 from MStraeten/patch-2
Enhanced x-touch.lua for colorequalizer
2025-01-02 15:45:34 -05:00
Krzysztof Kotowicz
0ab4b0cd6d Added 50ms sleep to the colorspace change.
The old profile still seems to be used occasionally without the sleep.
2025-01-02 15:21:59 +01:00
wpferguson
e2f5c993b8 Merge pull request #547 from darktable-org/update_links
README.md - updated links to current documentation
2025-01-01 17:36:24 -05:00
Bill Ferguson
3def8b43d9 README.md - fixed git clone links 2025-01-01 17:33:20 -05:00
Bill Ferguson
7b2942ca29 README.md fixed darktable-org to darktable.org 2025-01-01 17:28:03 -05:00
Bill Ferguson
bfed38f1db README.md - updated links to current documentation 2025-01-01 17:24:30 -05:00
Krzysztof Kotowicz
6d16a8778b Removed .DS_Store 2024-12-22 08:39:38 +01:00
Krzysztof Kotowicz
3c67c0c693 Create output directory if it doesn't exist. 2024-12-21 10:20:02 +01:00
Martin Straeten
1ba488d073 Enhanced x-touch.lua for colorequalizer
just adapted the colorzone stuff for color equalizer.
requires explicitly set the focus via a further switch
2024-12-15 19:08:24 +01:00
Krzysztof Kotowicz
3bdbfd12bf Apply write_image workaround only in 9.3.0 2024-12-05 00:45:34 +01:00
Krzysztof Kotowicz
77c405882d Added logging code and a workaround for https://github.com/darktable-org/darktable/issues/17528 in 9.4.0. 2024-12-03 19:49:57 +01:00
Krzysztof Kotowicz
ecc3fb29f5 Merge branch 'darktable-org:master' into ultrahdr 2024-12-03 07:20:51 +01:00
Krzysztof Kotowicz
a09140c03c Variable substitution in output directory. Force exporting files, but left the code to reconfigure it. Added more robust way to set export profile. 2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
95e2e4924e Added HDR only encoding variant.
- Increase timeouts to improve stability of choosing export profiles.
- Added peak nits setting.
2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
72ce0b0f93 Small fixes 2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
e4da226661 Added gainmap downsampling. 2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
a3404b64de Renamed gainmap to "gain map" in UI. 2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
13ae486ab3 Verify raw file size in pixels.
Added option to keep temporary files around for analysis.
2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
268aa7df42 Removed darktable capitalization. 2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
ea7ac8e474 Abort and cleanup early when any of the binaries fail. 2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
2a4452cd20 Workaround https://github.com/darktable-org/darktable/pull/17529 for future DT versions. 2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
749db97dd5 Speed up export in HDR+SDR. 2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
84a1c1ddfe Use single channel gainmaps in SDR+HDR. 2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
3791b47914 Use lowercase labels to adjust to DT style. 2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
f6a1d1c20b Made the UltraHDR generation more robust.
- Appropriate color profiles are used when generating UltraHDR files. DT_COLORSPACE_DISPLAY_P3 is used for SDR, and DT_COLORSPACE_PQ_P3 is used for HDR, regardless of what was selected in the export module UI.
- Added correction for odd image dimensions in SDR + HDR mode.
- Added quality setting for JPEG compression
- SDR + HDR option can source from any DT image (JPEG-XL is generated on the fly).
- generation can abort on errors and display error messages.
2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
118bbad5cb Exposed sliders for crucial settings in a metadata file, removed file picker for metadata.cfg.
Temporary metadata file will be created during UltraHDR generation, using the values from the UI.
2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
44e442e7eb Support grouping images into stacks by different criteria (or forcing the selection to be interpreted as a single UltraHDR stack). 2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
08726427aa Don't re-export JPEG files if their images are not altered. 2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
39f63cc8c5 Fallback to reading image's width/height, as sometimes final_width/height is 0. 2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
99c3548950 Copy only EXIF tags to UltraHDR. 2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
7e8340c526 Don't overwrite existing stack slots (that way the first matching image in a stack will be used, vs the last one). 2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
2e95c4837b Added option to generate metadata file.
Fixed pref load / saving code defaults.
Metadata file is required in ENCODING_VARIANT_SDR_AUTO_GAINMAP mode.
2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
fe6d262d9b Display all error messages when checking configuration.
Changed config defaults to require less user input (use_original_directory=TRUE, import_to_darktable=TRUE).
Account for nil values when checking for file existence.
2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
794b12305c Fixed tooltip language. 2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
1a3a7f17ff Added SDR + auto gainmap generation encoding variant. 2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
496f8e704d Fixed doc. 2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
28f87566e1 Added better messaging. 2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
865d29fb40 Changed the UltraHDR JPEG quality to 95. 2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
1986f4e148 Major overhaul of the plugin. Added SDR + HDR (JPEG-XL) support. 2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
b963eaa38c Added encoding variant as an explicit UI setting (though only API-4 is supported now). 2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
3b520d6291 Added UltraHDR export plugin to generate UltraHDR JPEG images. 2024-12-03 07:06:26 +01:00
Krzysztof Kotowicz
87ca8bde27 Fixed $(VERSION.IF_MULTI)
Variable substitution actually errored out when duplicates were present.
2024-12-02 23:45:12 +01:00
Krzysztof Kotowicz
d27df0c0ed Fixed $ROLL.NAME substitution
Accd to 57d3ad4bd3/src/common/variables.c (L660) it should be a basename of the file path.
2024-12-02 23:45:12 +01:00
Krzysztof Kotowicz
9bceb79a15 Fixed $(FILE.NAME) variable substitution
According to comments and https://docs.darktable.org/usermanual/4.6/en/special-topics/variables/ it should be a basename.
2024-12-02 23:45:12 +01:00
Bill Ferguson
ed9f2bb0d0 official/apply_camera_sytle - ensure style is only applied to raw
images.  Fixed comment.
2024-12-02 23:45:12 +01:00
Bill Ferguson
b5c93e55d1 lib/dtutils - added a gen_uuid() function to generate a UUID string
for use as a unique identifier.
2024-12-02 23:45:12 +01:00
Bill Ferguson
9bfda93b51 official/apply_camera_style - make the translatable strings
contrib/hif_group_leader      translation safe
contrib/jpg_group_leader
2024-12-02 23:45:12 +01:00
wpferguson
daa0877b4c Merge pull request #541 from koto/patch-1
Fixed variable substitutions
2024-12-02 11:22:02 -05:00
Krzysztof Kotowicz
da275c2a66 Fixed $(VERSION.IF_MULTI)
Variable substitution actually errored out when duplicates were present.
2024-12-02 10:11:23 +01:00
Krzysztof Kotowicz
3fa913d68a Fixed $ROLL.NAME substitution
Accd to 57d3ad4bd3/src/common/variables.c (L660) it should be a basename of the file path.
2024-12-02 09:56:45 +01:00
Krzysztof Kotowicz
e4e194b0ec Fixed $(FILE.NAME) variable substitution
According to comments and https://docs.darktable.org/usermanual/4.6/en/special-topics/variables/ it should be a basename.
2024-12-02 09:01:48 +01:00
wpferguson
c70ebf4297 Merge pull request #540 from wpferguson/fix_17914_apply_camera_style_to_raw
Ensure apply_camera_style is only applied to raw images
2024-12-01 12:50:23 -05:00
Bill Ferguson
7e3c6471ff official/apply_camera_sytle - ensure style is only applied to raw
images.  Fixed comment.
2024-12-01 12:27:16 -05:00
wpferguson
a39f6feba7 Merge pull request #536 from wpferguson/add_gen_uuid_to_dtutils_library
Add gen_uuid() function to dtutils library
2024-11-13 23:18:11 -05:00
wpferguson
ee7e92019a Merge pull request #537 from darktable-org/fix_collection_selection_strings_for_translation
Make strings translation safe
2024-11-13 23:17:51 -05:00
Bill Ferguson
96b7ba24bc official/apply_camera_style - make the translatable strings
contrib/hif_group_leader      translation safe
contrib/jpg_group_leader
2024-11-13 23:09:14 -05:00
Bill Ferguson
8581af29d2 lib/dtutils - added a gen_uuid() function to generate a UUID string
for use as a unique identifier.
2024-11-13 19:05:16 -05:00
wpferguson
ab532a724c Merge pull request #535 from Electro707/fix_file_path_sanitazation
Sanitization for file.mkdir and file.rmdir

Thanks
2024-11-03 13:25:13 -05:00
Electro707
b1e6bb8daa Added sanitization around path for mkdir and rmdir 2024-11-02 21:29:53 -04:00
wpferguson
26e797d5e5 Merge pull request #534 from wpferguson/autostyle_17731_EXIF_msg_fix
Fix contrib/autostyle confusing error message
2024-10-29 13:16:49 -04:00
wpferguson
3941c7ab52 Merge pull request #530 from wpferguson/fix_should_be_sanitized
fix string lib _should_be_sanitized
2024-10-29 13:16:35 -04:00
wpferguson
b7c8759af3 Merge pull request #523 from koto/sanitize_filename
Properly sanitize filenames in dtutils.file file_copy and file_move.

Thanks
2024-10-29 13:16:09 -04:00
Bill Ferguson
c5711880d3 contrib/autostyle - added check to make sure autostyle has been
configured so that it doesn't print out
                    a confusing error message.

                    Fixed preference to use translated script name,
                    (script_data.metadata.name).
2024-10-29 12:23:25 -04:00
Bill Ferguson
e7dbd6e369 lib/dtutils/string - fixed constant names to correctly define the
patterns as the unsafe filename characters.
2024-10-28 20:41:02 -04:00
Bill Ferguson
1b5cb1ae6e lib/dtutils/string - corrected constrant names in assignment 2024-10-28 20:18:22 -04:00
wpferguson
2953cf7e91 Merge pull request #533 from wpferguson/fix_rename_images
fix contrib/rename_images crash
2024-10-28 15:53:21 -04:00
Bill Ferguson
5e4ba9da5e contrib/rename_images - fixed call to sting library for building
the string substitution list
2024-10-28 15:49:32 -04:00
wpferguson
3a1f6bbc7a Merge pull request #531 from wpferguson/fix_17699_lua_translation
Lua Translation remaining tasks
2024-10-27 16:17:42 -04:00
Bill Ferguson
21ec2883fe Added translation to module name 2024-10-23 11:42:41 -04:00
Bill Ferguson
a0af01884d official/enfuse - fixed name and widget values to be translatable 2024-10-23 10:57:29 -04:00
Bill Ferguson
8e129d3825 tools/script_manager - added translated folder names.
cleaned up metadata handling (trimmed whitespace and comments)
		       fixed bug with folder names partially matching other folder names
2024-10-22 16:38:03 -04:00
Bill Ferguson
d123634679 Changed script names in metadata from filenames to names by
removing underscores, spelling out abbreviations, etc.
2024-10-22 00:13:30 -04:00
Bill Ferguson
13f210bec9 official/apply_camera_style - made name tranlatable and made it into
name string instead of the filename

tools/script_manager - change get_script_metadata to retreive a
                       script metadata block and store it as a
                       metadata block making the indiviual fields
                       accessible.

                       change the script buttons to use the translated
                       name if available and just the purpose for the
                       tooltip.
2024-10-21 22:52:11 -04:00
Bill Ferguson
8e4767c42a tools/script_manager - fixed translation of page %d of %d 2024-10-21 14:32:53 -04:00
Bill Ferguson
300bb9d443 lib/dtutils/string - fixed _should_be_sanitized with new patterns
to detect non safe filename characters
2024-10-19 22:04:56 -04:00
wpferguson
4f8df88fec Merge pull request #528 from wpferguson/fix_dgettext
fix message translation calls
2024-10-17 22:21:52 -04:00
Bill Ferguson
44da339202 alias gettext to darktable.gettext.gettext then change the call to gettext
to only pass the message to be translated
2024-10-17 22:17:38 -04:00
wpferguson
0e37a287ca Merge pull request #519 from koto/fix_gui_action
Fix types in examples/gui_action script.  Thanks
2024-10-16 21:21:03 -04:00
wpferguson
2af2530e45 Merge pull request #527 from wpferguson/fix_ret_val_return
fix string library missing end
2024-10-16 21:03:56 -04:00
Bill Ferguson
99eb595f4c lib/dtutils/string added end to if to ensure that ret_val has a value 2024-10-16 21:00:27 -04:00
wpferguson
31cdd3f24a Merge pull request #526 from wpferguson/substitution_library_updates
Substitution library updates
2024-10-16 20:50:15 -04:00
wpferguson
631d89e581 Merge pull request #518 from wpferguson/contrib_p_to_z
contrib/[p-z]*.lua - cleaned up translatable strings
2024-10-16 20:50:01 -04:00
wpferguson
526e97a4e1 Merge pull request #517 from wpferguson/contrib_g_to_o
contrib/[g-o]*.lua - clean up translatable strings
2024-10-16 20:49:46 -04:00
Bill Ferguson
a835ab89bc lib/dtutils/string - added new variable. Removed invalid variables.
Fixed build_substitute list call.  Fixed invalid
                     category return.
2024-10-16 00:42:08 -04:00
Bill Ferguson
da7674ca51 lib/dtutils/string - updated tooltip to use same translatable strings
as gui/gtkentry.c for consistency and eas of
                     translation
2024-10-14 12:54:13 -04:00
Bill Ferguson
0e535b850c Incorporated TurboGit's suggestions 2024-10-12 20:41:02 -04:00
Bill Ferguson
64840089e9 clean up a few issues with parentheses and missed strings. 2024-10-12 20:28:01 -04:00
Krzysztof Kotowicz
d754e37cf4 Properly sanitize filenames in dtutils.file file_copy and file_move. 2024-09-23 10:42:31 +02:00
Krzysztof Kotowicz
f6cb8091e7 Fix types in examples/gui_action script. 2024-09-20 15:07:37 +02:00
wpferguson
e359567620 Merge pull request #503 from wpferguson/apply_camera_styles
apply camera style
2024-09-17 18:33:55 -04:00
Bill Ferguson
36a6a54364 official/apply_camera_string - clean up translatable strings 2024-09-17 18:29:05 -04:00
Bill Ferguson
ecff920537 contrib/[p-z]*.lua - cleaned up translatable strings 2024-09-17 15:29:16 -04:00
Bill Ferguson
842f2fca66 contrib/[g-o]*.lua - clean up translatable strings 2024-09-16 21:16:34 -04:00
wpferguson
ee9384b9a7 Merge pull request #516 from wpferguson/contrib_a_to_f
contrib/[a-f]*.lua - fixed translatable strings to prepare for darktable translation
2024-09-16 20:06:38 -04:00
wpferguson
272c35499a Merge pull request #515 from wpferguson/tools_messages
updated tools/*.lua messages for translation
2024-09-16 20:06:18 -04:00
Bill Ferguson
4165d46b33 tools/script_manager - fixed capitalization 2024-09-16 20:04:29 -04:00
Bill Ferguson
5bed7c914c contrib/[a-f]*.lua - fixed translatable strings to prepare for darktable
translation
2024-09-13 19:47:44 -04:00
Bill Ferguson
684178b27e tools/script_manager - cleaned up translatable strings. Changed displayed
name to scripts.
2024-09-13 14:48:30 -04:00
Bill Ferguson
a72abe0ad0 tools/executable_manager - cleaned up translatable strings. Changed
displayed name to executables.
2024-09-13 14:47:37 -04:00
Bill Ferguson
8863f6ade7 tools/get*.lua - updated translable stirngs 2024-09-13 14:46:47 -04:00
wpferguson
e0463eba83 Merge pull request #512 from wpferguson/rename_images_variable_replacement
Rename images variable replacement
2024-09-13 14:14:36 -04:00
wpferguson
3a2ff7a7b9 Merge pull request #513 from wpferguson/example_messages
examples/*.lua - cleaned up translatable messages
2024-09-13 14:13:33 -04:00
Bill Ferguson
ce7ddfa472 examples/*.lua - cleaned up translatable messages 2024-09-10 21:19:37 -04:00
Bill Ferguson
3fb00586c3 contrib/rename_images - replaced pattern substitution code with the
better implementation in the string library.
2024-09-10 16:36:54 -04:00
Bill Ferguson
167620fadf lib/dtutils/string - Added get_substitution_tooltip() function to return
the substitution pattern tooltip so it only has to be
                     translated in one place.

                     Spelled substitution properly to that calls to
                     build_substitution_list succeed.
2024-09-10 16:34:36 -04:00
Bill Ferguson
f0bdc890c1 official/apply_camera_styles - updated message to specify the camera
styles are darktable camera styles
2024-09-09 22:50:49 -04:00
wpferguson
abdfc9de0b Merge pull request #510 from wpferguson/transllist
Fix string errors preventing translation
2024-09-08 23:51:05 -04:00
Bill Ferguson
c65e070a76 contrib/geoJSON - don't translate the module name 2024-09-08 23:43:39 -04:00
Bill Ferguson
05c57c0247 contrib/geoTooxbox - fix latitude and longitude labels 2024-09-08 23:42:45 -04:00
Bill Ferguson
5be3d6986e official/selection_to_pdf - dont translate internal module name 2024-09-08 23:24:33 -04:00
Bill Ferguson
f0e3627800 contrib/exportLUT - fixed file chooser labels for translation 2024-09-08 22:54:42 -04:00
Bill Ferguson
ea08557a0e tools/script_manager - removed translation of empty string 2024-09-08 22:49:50 -04:00
wpferguson
ac43afd8c9 Merge pull request #507 from darktable-org/fix_contrib_rate_group
contrib/rate_group - fixed formatting of tooltips
2024-09-03 19:16:19 -04:00
Bill Ferguson
ee98f96a4f contrib/rate_group - fixed formatting of tooltips 2024-09-03 19:14:31 -04:00
wpferguson
a33eeddbd6 Merge pull request #504 from darktable-org/darktable_translate_official
prepare official scripts for darktable translation
2024-08-31 21:44:41 -04:00
wpferguson
746c6a07c4 Merge pull request #505 from darktable-org/script_manager_running_script
tools/script_manager - restore script_manager_running_script
2024-08-31 21:43:55 -04:00
Bill Ferguson
96fee48ef2 tools/script_manager - restore script_manager_running_script to
"script_manager" after changing it to start
                       a script
2024-08-31 21:39:50 -04:00
Bill Ferguson
6c686128d2 official/*.lua - Removed bindtextdoamin so that darktable can handle
the translation
2024-08-31 16:48:36 -04:00
Bill Ferguson
6ca8f0034e official/apply_camera_style - apply a darktable camera style to an
image based on maker and model.  Styles
                              may be applied to selections, collections,
                              and on newly imported images.
2024-08-31 15:53:38 -04:00
153 changed files with 21240 additions and 901 deletions

138
README.md
View File

@@ -15,17 +15,17 @@ These scripts are written primarily by the darktable developers and maintained b
Name|Standalone|OS |Purpose
----|:--------:|:---:|-------
[check_for_updates](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/official/check_for_updates)|Yes|LMW|Check for updates to darktable
[copy_paste_metadata](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/official/copy_paste_metadata)|Yes|LMW|Copy and paste metadata, tags, ratings, and color labels between images
[delete_long_tags](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/official/delete_long_tags)|Yes|LMW|Delete all tags longer than a specified length
[delete_unused_tags](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/official/delete_unused_tags)|Yes|LMW|Delete tags that have no associated images
[enfuse](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/official/enfuse)|No|L|Exposure blend several images (HDR)
[generate_image_txt](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/official/generate_image_txt)|No|L|Generate txt sidecar files to be overlaid on zoomed images
[image_path_in_ui](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/official/image_path_in_ui)|Yes|LMW|Plugin to display selected image path
[import_filter_manager](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/official/import_filter_manager)|Yes|LMW|Manager for import filters
[import_filters](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/official/import_filters)|No|LMW|Two import filters for use with import_filter_manager
[save_selection](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/official/save_selection)|Yes|LMW|Provide save and restore from multiple selection buffers
[selection_to_pdf](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/official/selection_to_pdf)|No|L|Generate a PDF file from the selected images
[check_for_updates](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/check_for_updates)|Yes|LMW|Check for updates to darktable
[copy_paste_metadata](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/copy_paste_metadata)|Yes|LMW|Copy and paste metadata, tags, ratings, and color labels between images
[delete_long_tags](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/delete_long_tags)|Yes|LMW|Delete all tags longer than a specified length
[delete_unused_tags](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/delete_unused_tags)|Yes|LMW|Delete tags that have no associated images
[enfuse](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/enfuse)|No|L|Exposure blend several images (HDR)
[generate_image_txt](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/generate_image_txt)|No|L|Generate txt sidecar files to be overlaid on zoomed images
[image_path_in_ui](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/image_path_in_ui)|Yes|LMW|Plugin to display selected image path
[import_filter_manager](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/import_filter_manager)|Yes|LMW|Manager for import filters
[import_filters](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/import_filters)|No|LMW|Two import filters for use with import_filter_manager
[save_selection](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/save_selection)|Yes|LMW|Provide save and restore from multiple selection buffers
[selection_to_pdf](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/selection_to_pdf)|No|L|Generate a PDF file from the selected images
### Contributed Scripts
@@ -34,50 +34,52 @@ These scripts are contributed by users. They are meant to have an "owner", i.e.
Name|Standalone|OS |Purpose
----|:--------:|:---:|-------
[AutoGrouper](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/autogrouper)|Yes|LMW|Group images together by time
[autostyle](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/autostyle)|Yes|LMW|Automatically apply styles on import
[AutoGrouper](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/autogrouper)|Yes|LMW|Group images together by time
[autostyle](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/autostyle)|Yes|LMW|Automatically apply styles on import
[auto_tag_modified](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/auto_tag_modified)|Yes|LMW|Automatically tag images which have been manually modified in the darkroom
change_group_leader|Yes|LMW|Change which image leads group
[clear_GPS](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/clear_gps)|Yes|LMW|Reset GPS information for selected images
[CollectHelper](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/collecthelper)|Yes|LMW|Add buttons to selected images module to manipulate the collection
[clear_GPS](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/clear_gps)|Yes|LMW|Reset GPS information for selected images
[CollectHelper](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/collecthelper)|Yes|LMW|Add buttons to selected images module to manipulate the collection
color_profile_manager|Yes|LMW|Manage darktable input and output color profiles
[copy_attach_detach_tags](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/copy_attach_detach_tags)|Yes|LMW|Copy and paste tags from/to images
[cr2hdr](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/cr2hdr)|Yes|L|Process image created with Magic Lantern Dual ISO
[copy_attach_detach_tags](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/copy_attach_detach_tags)|Yes|LMW|Copy and paste tags from/to images
[cr2hdr](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/cr2hdr)|Yes|L|Process image created with Magic Lantern Dual ISO
cycle_group_leader|Yes|LMW|cycle through images of a group making each the group leader in turn
dbmaint|Yes|LMW|find and remove database entries for missing film rolls and images
[enfuseAdvanced](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/enfuseadvanced)|No|LMW|Merge multiple images into Dynamic Range Increase (DRI) or Depth From Focus (DFF) images
[exportLUT](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/exportlut)|Yes|LMW|Create a LUT from a style and export it
[ext_editor](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/ext_editor)|No|LW|Export pictures to collection and edit them with up to nine user-defined external editors
[face_recognition](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/face_recognition)|No|LM|Identify and tag images using facial recognition
[fujifilm_dynamic_range](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/fujifilm_dynamic_range)|No|LMW|Correct fujifilm exposure based on exposure bias camera setting
[fujifilm_ratings](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/fujifilm_ratings)|No|LM|Support importing Fujifilm ratings
[geoJSON_export](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/geojson_export)|No|L|Create a geo JSON script with thumbnails for use in ...
[geoToolbox](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/geotoolbox)|No|LMW|A toolbox of geo functions
[gimp](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/gimp)|No|LMW|Open an image in GIMP for editing and return the result
[gpx_export](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/gpx_export)|No|LMW|Export a GPX track file from selected images GPS data
[enfuseAdvanced](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/enfuseadvanced)|No|LMW|Merge multiple images into Dynamic Range Increase (DRI) or Depth From Focus (DFF) images
[exportLUT](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/exportlut)|Yes|LMW|Create a LUT from a style and export it
[ext_editor](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/ext_editor)|No|LW|Export pictures to collection and edit them with up to nine user-defined external editors
[face_recognition](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/face_recognition)|No|LM|Identify and tag images using facial recognition
[fujifilm_dynamic_range](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/fujifilm_dynamic_range)|No|LMW|Correct fujifilm exposure based on exposure bias camera setting
[fujifilm_ratings](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/fujifilm_ratings)|No|LM|Support importing Fujifilm ratings
[geoJSON_export](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/geojson_export)|No|L|Create a geo JSON script with thumbnails for use in ...
[geoToolbox](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/geotoolbox)|No|LMW|A toolbox of geo functions
[gimp](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/gimp)|No|LMW|Open an image in GIMP for editing and return the result
[gpx_export](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/gpx_export)|No|LMW|Export a GPX track file from selected images GPS data
harmonic_armature|Yes|LMW|provide a harmonic armature guide
hif_group_leader|Yes|LMW|change the group leader in a raw+heif image pair to the heif image
[HDRMerge](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/hdrmerge)|No|LMW|Combine the selected images into an HDR DNG and return the result
[hugin](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/hugin)|No|LMW|Combine selected images into a panorama and return the result
[image_stack](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/image_stack)|No|LMW|Combine a stack of images to remove noise or transient objects
[image_time](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/image_time)|Yes|LMW|Adjust the EXIF image time
[HDRMerge](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/hdrmerge)|No|LMW|Combine the selected images into an HDR DNG and return the result
[hugin](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/hugin)|No|LMW|Combine selected images into a panorama and return the result
[image_stack](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/image_stack)|No|LMW|Combine a stack of images to remove noise or transient objects
[image_time](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/image_time)|Yes|LMW|Adjust the EXIF image time
jpg_group_leader|Yes|LMW|change the group leader for a raw+jpg pair to the jpg image
[kml_export](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/kml_export)|No|L|Export photos with a KML file for usage in Google Earth
[LabelsToTags](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/labelstotags)|Yes|LMW|Apply tags based on color labels and ratings
[OpenInExplorer](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/openinexplorer)|No|LMW|Open the selected images in the system file manager
[passport_guide](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/passport_guide)|Yes|LMW|Add passport cropping guide to darkroom crop tool
[kml_export](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/kml_export)|No|L|Export photos with a KML file for usage in Google Earth
[LabelsToTags](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/labelstotags)|Yes|LMW|Apply tags based on color labels and ratings
[OpenInExplorer](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/openinexplorer)|No|LMW|Open the selected images in the system file manager
[passport_guide](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/passport_guide)|Yes|LMW|Add passport cropping guide to darkroom crop tool
passport_guide_germany|Yes|LMW|Add passport cropping guide for German passports to darkroom crop tool
[pdf_slideshow](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/pdf_slideshow)|No|LM|Export images to a PDF slideshow
[photils](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/photils)|No|LM|Automatic tag suggestions for your images
[quicktag](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/quicktag)|Yes|LMW|Create shortcuts for quickly applying tags
[rate_group](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/rate_group)|Yes|LMW|Apply or remove a star rating from grouped images
[rename_images](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/rename_images)|Yes|LMW|Rename single or multiple images
[rename-tags](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/rename-tags)|Yes|LMW|Change a tag name
[RL_out_sharp](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/rl_out_sharp)|No|LW|Output sharpening using GMic (Richardson-Lucy algorithm)
[select_non_existing](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/select_non_existing)|Yes|LMW|Enable selection of non-existing images in the the currently worked on images
[select_untagged](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/select_untagged)|Yes|LMW|Enable selection of untagged images
[slideshowMusic](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/slideshowMusic)|No|L|Play music during a slideshow
[transfer_hierarchy](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/transfer_hierarchy)|Yes|LMW|Image move/copy preserving directory hierarchy
[video_ffmpeg](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/contrib/video_ffmpeg)|No|LMW|Export video from darktable
[pdf_slideshow](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/pdf_slideshow)|No|LM|Export images to a PDF slideshow
[photils](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/photils)|No|LM|Automatic tag suggestions for your images
[quicktag](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/quicktag)|Yes|LMW|Create shortcuts for quickly applying tags
[rate_group](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/rate_group)|Yes|LMW|Apply or remove a star rating from grouped images
[rename_images](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/rename_images)|Yes|LMW|Rename single or multiple images
[rename-tags](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/rename-tags)|Yes|LMW|Change a tag name
[RL_out_sharp](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/rl_out_sharp)|No|LW|Output sharpening using GMic (Richardson-Lucy algorithm)
[select_non_existing](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/select_non_existing)|Yes|LMW|Enable selection of non-existing images in the the currently worked on images
[select_untagged](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/select_untagged)|Yes|LMW|Enable selection of untagged images
[slideshowMusic](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/slideshowMusic)|No|L|Play music during a slideshow
[transfer_hierarchy](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/transfer_hierarchy)|Yes|LMW|Image move/copy preserving directory hierarchy
[video_ffmpeg](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/video_ffmpeg)|No|LMW|Export video from darktable
website_gallery_export|No|LMW|Export a website gallery for selected images
### Example Scripts
@@ -85,18 +87,18 @@ These scripts provide examples of how to use specific portions of the API. They
Name|Standalone|OS |Purpose
----|:--------:|:---:|-------
[api_version](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/examples/api_version)|Yes|LMW|Print the current API version
[darkroom_demo](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/examples/darkroom_demo)|Yes|LMW|Demonstrate changing images in darkoom
[gettextExample](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/examples/gettextexample)|Yes|LM|How to use translation
[api_version](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/api_version)|Yes|LMW|Print the current API version
[darkroom_demo](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/darkroom_demo)|Yes|LMW|Demonstrate changing images in darkoom
[gettextExample](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/gettextexample)|Yes|LM|How to use translation
gui_actions|Yes|LMW|demonstrate controlling the GUI using darktable.gui.action calls
[hello_world](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/examples/hello_world)|Yes|LMW|Prints hello world when darktable starts
[lighttable_demo](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/examples/lighttable_demo)|Yes|LMW|Demonstrate controlling lighttable mode, zoom, sorting and filtering
[moduleExample](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/examples/moduleexample)|Yes|LMW|How to create a lighttable module
[multi_os](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/examples/multi_os)|No|LMW|How to create a cross platform script that calls an external executable
[panels_demo](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/examples/panels_demo)|Yes|LMW|Demonstrate hiding and showing darktable panels
[preferenceExamples](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/examples/preferenceexamples)|Yes|LMW|How to use preferences in a script
[printExamples](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/examples/printexamples)|Yes|LMW|How to use various print functions from a script
[running_os](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/examples/running_os)|Yes|LMW|Print out the running operating system
[hello_world](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/hello_world)|Yes|LMW|Prints hello world when darktable starts
[lighttable_demo](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/lighttable_demo)|Yes|LMW|Demonstrate controlling lighttable mode, zoom, sorting and filtering
[moduleExample](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/moduleexample)|Yes|LMW|How to create a lighttable module
[multi_os](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/multi_os)|No|LMW|How to create a cross platform script that calls an external executable
[panels_demo](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/panels_demo)|Yes|LMW|Demonstrate hiding and showing darktable panels
[preferenceExamples](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/preferenceexamples)|Yes|LMW|How to use preferences in a script
[printExamples](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/printexamples)|Yes|LMW|How to use various print functions from a script
[running_os](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/running_os)|Yes|LMW|Print out the running operating system
x-touch|Yes|LMW|demonstrate how to use an x-touch mini MIDI controller to control the darktable GUI
### Tools
@@ -105,11 +107,11 @@ Tool scripts perform functions relating to the repository, such as generating do
Name|Standalone|OS |Purpose
----|:--------:|:---:|-------
[executable_manager](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/tools/executable_manager)|Yes|LMW|Manage the external executables used by the lua scripts
[gen_i18n_mo](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/tools/gen_i18n_mo)|No|LMW|Generate compiled translation files (.mo) from source files (.po)
[get_lib_manpages](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/tools/get_lib_manpages)|No|LM|Retrieve the library documentation and output it in man page and PDF format
[get_libdoc](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/tools/get_libdoc)|No|LMW|Retrieve the library documentation and output it as text
[script_manager](https://darktable-org.github.io/luadocs/lua.scripts.manual/scripts/tools/script_manager)|No|LMW|Manage (install, update, enable, disable) the lua scripts
[executable_manager](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/tools/executable_manager)|Yes|LMW|Manage the external executables used by the lua scripts
[gen_i18n_mo](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/tools/gen_i18n_mo)|No|LMW|Generate compiled translation files (.mo) from source files (.po)
[get_lib_manpages](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/tools/get_lib_manpages)|No|LM|Retrieve the library documentation and output it in man page and PDF format
[get_libdoc](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/tools/get_libdoc)|No|LMW|Retrieve the library documentation and output it as text
[script_manager](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/tools/script_manager)|No|LMW|Manage (install, update, enable, disable) the lua scripts
### Related third-party projects
@@ -178,7 +180,7 @@ If you don't have %LOCALAPPDATA%\darktable you have to start dartable at least o
When darktable starts it looks for a file name `~/.config/darktable/luarc` (`%LOCALAPPDATA%\darktable\luarc` for windows) and reads it to see which scripts to include. The file is a plain text file with entries of the form `require "<directory>/<name>"` where directory is the directory containing the scripts, from the above list, and name is the name from the above list. To include GIMP the line would be `require "contrib/gimp"`.
The recommended way to enable and disable specific scripts is using the script manager module. To use script manager do the following:
The recommended way to enable and disable specific scripts is using the [script manager](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/tools/script_manager/) module. To use script manager do the following:
### Linux or MacOS
@@ -237,18 +239,18 @@ To update the script repository, open a terminal or command prompt and do the fo
## Documentation
The [Lua Scripts Manual](https://darktable-org.github.io/luadocs/lua.scripts.manual/) provides documentation
The [Lua Scripts Manual](https://docs.darktable.org/lua/stable/lua.scripts.manual/) provides documentation
for the scripts transcribed from the header comments. Each script also contains comments and usage instructions
in the header comments.
The [Lua Scripts Library API Manual](https://darktable-org.github.io/luadocs/lua.scripts.api.manual/) provides
The [Lua Scripts Library API Manual](https://docs.darktable.org/lua/stable/lua.scripts.api.manual/) provides
documentation of the libraries and functions. Lua-script libraries documentation may also be generated using
the tools in the tools/ directory.
More information about the scripting with Lua can be found in the darktable user manual:
[Scripting with Lua](https://darktable-org.github.io/dtdocs/lua/)
[Scripting with Lua](https://darktable.org.github.io/dtdocs/lua/)
The [Lua API Manual](https://darktable-org.github.io/luadocs/lua.api.manual/) provides docuemntation of the
The [Lua API Manual](https://docs.darktable.org/lua/stable/lua.api.manual/) provides docuemntation of the
darktable Lua API.
## Troubleshooting

View File

@@ -43,11 +43,10 @@ du.check_min_api_version("7.0.0", "AutoGrouper")
local MOD = 'autogrouper'
local gettext = dt.gettext
gettext.bindtextdomain(MOD, dt.configuration.config_dir .."/lua/locale/")
local gettext = dt.gettext.gettext
local function _(msgid)
return gettext.gettext(msgid)
return gettext(msgid)
end
-- return data structure for script_manager
@@ -55,7 +54,7 @@ end
local script_data = {}
script_data.metadata = {
name = "AutoGrouper",
name = _("auto group"),
purpose = _("automatically group images by time interval"),
author = "Kevin Ertel",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/AutoGrouper/"
@@ -133,10 +132,11 @@ local function main(on_collection)
for i, image in ipairs(images) do
if i == 1 then
prev_image = image
image:make_group_leader()
elseif string.match(image.exif_datetime_taken, '[%d]') ~= nil then --make sure current image has a timestamp, if so check if it is within the user specified gap value and add to group
local curr_image = image
if GetTimeDiff(curr_image, prev_image) <= GUI.gap.value then
images[i]:group_with(images[i-1])
images[i]:group_with(images[i-1].group_leader)
end
prev_image = curr_image
end

View File

@@ -53,8 +53,6 @@ local all_active = false
du.check_min_api_version("7.0.0", "CollectHelper")
dt.gettext.bindtextdomain("CollectHelper", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -64,7 +62,7 @@ end
local script_data = {}
script_data.metadata = {
name = "CollectHelper",
name = _("collection helper"),
purpose = _("add collection helper buttons"),
author = "Kevin Ertel",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/CollectHelper/"
@@ -239,7 +237,7 @@ end
dt.preferences.register("module_CollectHelper", "all_and", -- name
"bool", -- type
_('CollectHelper: all'), -- label
_('will create a collect parameter set that utilizes all enabled CollectHelper types (and)'), -- tooltip
_('creates a collect parameter set that utilizes all enabled CollectHelper types (and)'), -- tooltip
true -- default
)
dt.preferences.register("module_CollectHelper", "colors", -- name

View File

@@ -54,8 +54,6 @@ du.check_min_api_version("7.0.0", "HDRmerge")
-- Tell gettext where to find the .mo file translating messages for a particular domain
local gettext = dt.gettext.gettext
dt.gettext.bindtextdomain("HDRMerge", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -65,7 +63,7 @@ end
local script_data = {}
script_data.metadata = {
name = "HDRmerge",
name = _("HDR merge"),
purpose = _("merge bracketed images into an HDR DNG image"),
author = "Kevin Ertel",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/HDRmerge"
@@ -218,7 +216,7 @@ local function main()
PreCall({HDRM}) --check if furst run then check if install OK
if HDRM.install_error then
dt.print_error('HDRMerge install issue')
dt.print(_('HDRMerge install issue, please ensure the binary path is proper'))
dt.print(_('HDRMerge install issue, please ensure the binary path is correct'))
return
end
images = dt.gui.selection() --get selected images
@@ -416,14 +414,14 @@ GUI.Target.copy_tags = dt.new_widget('check_button'){
temp = dt.preferences.read(mod, 'active_add_tags', 'string')
if temp == '' then temp = nil end
GUI.Target.add_tags = dt.new_widget('entry'){
tooltip = _('additional tags to be added on import, seperate with commas, all spaces will be removed'),
tooltip = _('additional tags to be added on import, separate with commas, all spaces will be removed'),
text = temp,
placeholder = _('enter tags, seperated by commas'),
placeholder = _('enter tags, separated by commas'),
editable = true
}
GUI.run = dt.new_widget('button'){
label = _('merge'),
tooltip =_('run HDRMerge with the above specified settings'),
tooltip =_('run HDRMerge with the above settings'),
clicked_callback = function() main() end
}
GUI.exes.HDRMerge = dt.new_widget('file_chooser_button'){

View File

@@ -54,8 +54,6 @@ du.check_min_api_version("7.0.0", "LabelsToTags")
local gettext = darktable.gettext.gettext
darktable.gettext.bindtextdomain("LabelsToTags", darktable.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -65,7 +63,7 @@ end
local script_data = {}
script_data.metadata = {
name = "LabelsToTags",
name = _("labels to tags"),
purpose = _("allows the mass-application of tags using color labels and ratings as a guide"),
author = "August Schwerdfeger (august@schwerdfeger.name)",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/LabelsToTags"
@@ -216,8 +214,8 @@ ltt.my_widget = darktable.new_widget("box") {
orientation = "vertical",
mappingComboBox,
darktable.new_widget("button") {
label = "start",
tooltip = "Tag all selected images",
label = _("start"),
tooltip = _("tag all selected images"),
clicked_callback = doTagging
}
}
@@ -247,7 +245,7 @@ end
local function install_module()
if not ltt.module_installed then
darktable.register_lib(LIB_ID,"labels to tags",true,true,{
darktable.register_lib(LIB_ID,_("labels to tags"),true,true,{
[darktable.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER",20},
},ltt.my_widget,nil,nil)
ltt.module_installed = true

View File

@@ -56,8 +56,6 @@ local gettext = dt.gettext.gettext
--Check API version
du.check_min_api_version("7.0.0", "OpenInExplorer")
dt.gettext.bindtextdomain("OpenInExplorer", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -67,7 +65,7 @@ end
local script_data = {}
script_data.metadata = {
name = "OpenInExplorer",
name = _("open in explorer"),
purpose = _("open a selected file in the system file manager"),
author = "Kevin Ertel",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/OpenInExplorer"

View File

@@ -68,8 +68,6 @@ du.check_min_api_version("7.0.0", MODULE_NAME)
-- translation
local gettext = dt.gettext.gettext
dt.gettext.bindtextdomain("RL_out_sharp", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -79,7 +77,7 @@ local function _(msgid)
local script_data = {}
script_data.metadata = {
name = "RL_out_sharp",
name = _("RL output sharpening"),
purpose = _("Richardson-Lucy output sharpening using GMic"),
author = "Marco Carrarini <marco.carrarini@gmail.com>",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/RL_out_sharp"
@@ -108,7 +106,7 @@ local function preserve_metadata(original, sharpened)
if exiftool then
dtsys.external_command("exiftool -overwrite_original_in_place -tagsFromFile " .. original .. " " .. sharpened)
else
dt.print_log(MODULE .. " exiftool not found, metadata not preserved")
dt.print_log(MODULE_NAME .. " exiftool not found, metadata not preserved")
end
end

View File

@@ -59,10 +59,8 @@ du.check_min_api_version("7.0.0", MODULE) -- choose the minimum version that c
local gettext = dt.gettext.gettext
dt.gettext.bindtextdomain(MODULE , dt.configuration.config_dir .. "/lua/locale/")
local function _(msgid)
return gettext(MODULE, msgid)
return gettext(msgid)
end
@@ -78,10 +76,10 @@ script_data.restart = nil -- how to restart the (lib) script after it'
script_data.show = nil -- only required for libs since the destroy_method only hides them
script_data.metadata = {
name = "auto_snapshot", -- name of script
name = _("auto snapshot"), -- name of script
purpose = _("automatically take a snapshot when an image is loaded in darkroom"), -- purpose of script
author = "Bill Ferguson <wpferguson@gamil.com>", -- your name and optionally e-mail address
help = "" -- URL to help/documentation
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/auto_snapshot/" -- URL to help/documentation
}
@@ -101,8 +99,8 @@ local auto_snapshot = {}
-- P R E F E R E N C E S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.preferences.register(MODULE, "always_create_snapshot", "bool", "always automatically create_snapshot",
"auto_snapshot - create a snapshot even if the image is altered", false)
dt.preferences.register(MODULE, "always_create_snapshot", "bool", "auto_snapshot - " .. _("always automatically create_snapshot"),
_("create a snapshot even if the image is altered"), false)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- D A R K T A B L E I N T E G R A T I O N

View File

@@ -0,0 +1,129 @@
--[[
auto_tag_modified.lua - automatically tag an image when it has been manually modified in darkroom
Copyright (C) 2025 Michael Reiger <michael@rauschpfeife.net>, modeled after auto_snapshot.lua by Bill Ferguson <wpferguson@gamil.com>.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
]]
--[[
auto_tag_modified -
automatically tag an image when it has been manually modified in the darkroom, that is if its history has changed at the time it is closed from the darkroom.
(This will not catch applying styles from the light table though.)
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
None
USAGE
* start the script from script_manager
* open an image in darkroom, and close it after making an operation that changes the history
BUGS, COMMENTS, SUGGESTIONS
Michael Reiger <michael@rauschpfeife.net>
CHANGES
]]
local dt = require "darktable"
local du = require "lib/dtutils"
local log = require "lib/dtutils.log"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- C O N S T A N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE <const> = "auto_tag_modified"
local DEFAULT_TAG_NAME <const> = "darktable manually modified"
local DEFAULT_LOG_LEVEL <const> = log.error
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A P I C H E C K
-- - - - - - - - - - - - - - - - - - - - - - - -
du.check_min_api_version("7.0.0", MODULE) -- choose the minimum version that contains the features you need
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- I 1 8 N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
local gettext = dt.gettext.gettext
local function _(msgid)
return gettext(msgid)
end
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- S C R I P T M A N A G E R I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
local script_data = {}
script_data.destroy = nil -- function to destory the script
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
script_data.show = nil -- only required for libs since the destroy_method only hides them
script_data.metadata = {
name = _("auto tag modified"), -- name of script
purpose = _("automatically tag an image when it has been manually modified in darkroom"), -- purpose of script
author = "Michael Reiger <michael@rauschpfeife.net>", -- your name and optionally e-mail address
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/auto_tag_modified/" -- URL to help/documentation
}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- L O G L E V E L
-- - - - - - - - - - - - - - - - - - - - - - - -
log.log_level(DEFAULT_LOG_LEVEL)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- N A M E S P A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
local auto_tag_modified = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- P R E F E R E N C E S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.preferences.register(MODULE, "auto_modified_tag_name", "string", "auto_tag_modified - " .. _("tag name to use for auto tagging modified images"),
_("tag name to use for auto tagging modified images"), DEFAULT_TAG_NAME)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- D A R K T A B L E I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - -
local function destroy()
dt.destroy_event(MODULE, "darkroom-image-history-changed")
end
script_data.destroy = destroy
-- - - - - - - - - - - - - - - - - - - - - - - -
-- E V E N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.register_event(MODULE, "darkroom-image-history-changed",
function(event, image)
local tag_name = dt.preferences.read(MODULE, "auto_modified_tag_name", "string")
local tag = dt.tags.create(tag_name)
dt.tags.attach(tag, image)
end
)
return script_data

View File

@@ -44,7 +44,6 @@ local syslib = require "lib/dtutils.system"
du.check_min_api_version("7.0.0", "autostyle")
local gettext = darktable.gettext.gettext
darktable.gettext.bindtextdomain("autostyle", darktable.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
@@ -54,8 +53,10 @@ end
local script_data = {}
local have_not_printed_config_message = true
script_data.metadata = {
name = "autostyle",
name = _("auto style"),
purpose = _("automatically apply a style based on image EXIF tag"),
author = "Marc Cousin <cousinmarc@gmail.com>",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/autostyle/"
@@ -102,56 +103,62 @@ end
local function autostyle_apply_one_image (image)
local pref = darktable.preferences.read("autostyle", "exif_tag", "string")
if pref and string.len(pref) >= 6 then
-- We need the tag, the value and the style_name provided from the configuration string
local tag, value, style_name = string.match(pref, "(%g+)%s*=%s*(%g+)%s*=>%s*(%g+)")
local tag, value, style_name = string.match(pref, "(%g+)%s*=%s*([%g ]-)%s*=>%s*(%g+)")
-- check they all exist (correct syntax)
if (not tag) then
darktable.print(string.format(_("EXIF tag not found in %s"), pref))
return 0
end
if (not value) then
darktable.print(string.format(_("value to match not found in %s"), pref))
return 0
end
if (not style_name) then
darktable.print(string.format(_("style name not found in %s"), pref))
return 0
end
if not filelib.check_if_bin_exists("exiftool") then
darktable.print(_("can't find exiftool"))
return 0
end
-- First find the style (we have its name)
local styles = darktable.styles
local style
for _, s in ipairs(styles) do
if s.name == style_name then
style = s
end
end
if (not style) then
darktable.print(string.format(_("style not found for autostyle: %s"), style_name))
return 0
end
-- check they all exist (correct syntax)
if (not tag) then
darktable.print(string.format(_("EXIF tag not found in %s"), pref))
return 0
end
if (not value) then
darktable.print(string.format(_("value to match not found in %s"), pref))
return 0
end
if (not style_name) then
darktable.print(string.format(_("style name not found in %s"), pref))
return 0
end
if not filelib.check_if_bin_exists("exiftool") then
darktable.print(_("can't find exiftool"))
return 0
end
-- First find the style (we have its name)
local styles = darktable.styles
local style
for _, s in ipairs(styles) do
if s.name == style_name then
style = s
end
end
if (not style) then
darktable.print(string.format(_("style not found for autostyle: %s"), style_name))
return 0
end
-- Apply the style to image, if it is tagged
local ok, auto_dr_attr = pcall(exiftool_attribute, image.path .. '/' .. image.filename,tag)
--darktable.print_error("dr_attr:" .. auto_dr_attr)
-- If the lookup fails, stop here
if (not ok) then
darktable.print(string.format(_("couldn't get attribute %s from exiftool's output"), auto_dr_attr))
return 0
end
if auto_dr_attr == value then
darktable.print_log("Image " .. image.filename .. ": autostyle automatically applied " .. pref)
darktable.styles.apply(style,image)
return 1
else
darktable.print_log("Image " .. image.filename .. ": autostyle not applied, exif tag " .. pref .. " not matched: " .. auto_dr_attr)
return 0
-- Apply the style to image, if it is tagged
local ok, auto_dr_attr = pcall(exiftool_attribute, image.path .. '/' .. image.filename,tag)
--darktable.print_error("dr_attr:" .. auto_dr_attr)
-- If the lookup fails, stop here
if (not ok) then
darktable.print(string.format(_("couldn't get attribute %s from exiftool's output"), auto_dr_attr))
return 0
end
if auto_dr_attr == value then
darktable.print_log("Image " .. image.filename .. ": autostyle automatically applied " .. pref)
darktable.styles.apply(style,image)
return 1
else
darktable.print_log("Image " .. image.filename .. ": autostyle not applied, exif tag " .. pref .. " not matched: " .. auto_dr_attr)
return 0
end
elseif have_not_printed_config_message then
have_not_printed_config_message = false
darktable.print(string.format(_("%s is not configured, please configure the preference in Lua options"), script_data.metadata.name))
end
end
@@ -181,7 +188,9 @@ end
darktable.register_event("autostyle", "shortcut", autostyle_apply,
_("apply your chosen style from exiftool tags"))
darktable.preferences.register("autostyle", "exif_tag", "string", "Autostyle: EXIF_tag=value=>style", _("apply a style automatically if an EXIF tag matches value, find the tag with exiftool"), "")
darktable.preferences.register("autostyle", "exif_tag", "string",
string.format("%s: EXIF_tag=value=>style", script_data.metadata.name),
_("apply a style automatically if an EXIF tag matches value, find the tag with exiftool"), "")
darktable.register_event("autostyle", "post-import-image",
autostyle_apply_one_image_event)

View File

@@ -40,8 +40,6 @@ local gettext = dt.gettext.gettext
local MODULE = "change_group_leader"
dt.gettext.bindtextdomain(MODULE, dt.configuration.config_dir .."/lua/locale/")
du.check_min_api_version("3.0.0", MODULE)
local function _(msgid)
@@ -53,7 +51,7 @@ end
local script_data = {}
script_data.metadata = {
name = "change_group_leader",
name = _("change group leader"),
purpose = _("automatically change the leader of raw+jpg paired image groups"),
author = "Bill Ferguson <wpferguson@gmail.com>",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/change_group_leader"
@@ -65,7 +63,7 @@ script_data.restart = nil -- how to restart the (lib) script after it's been hid
script_data.show = nil -- only required for libs since the destroy_method only hides them
-- create a namespace to contain persistent data and widgets
chg_grp_ldr = {}
local chg_grp_ldr = {}
local cgl = chg_grp_ldr
@@ -82,7 +80,7 @@ local function install_module()
if not cgl.module_installed then
dt.register_lib(
MODULE, -- Module name
_("change_group_leader"), -- Visible name
_("change group leader"), -- Visible name
true, -- expandable
false, -- resetable
{[dt.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 700}}, -- containers

View File

@@ -40,7 +40,6 @@ local dt = require "darktable"
local du = require "lib/dtutils"
local gettext = dt.gettext.gettext
dt.gettext.bindtextdomain("clear_GPS", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
@@ -51,7 +50,7 @@ end
local script_data = {}
script_data.metadata = {
name = "clear_GPS",
name = _("clear GPS info"),
purpose = _("remove GPS data from selected image(s)"),
author = "Bill Ferguson <wpferguson@gmail.com>",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/clear_gps/"
@@ -92,7 +91,7 @@ dt.gui.libs.image.register_action(
dt.register_event(
"clear_GPS", "shortcut",
function(event, shortcut) clear_GPS(dt.gui.action_images) end,
_("clear GPS data")
_("clear GPS data from selected images")
)
return script_data

View File

@@ -55,8 +55,6 @@ du.check_min_api_version("7.0.0", "color_profile_manager")
local gettext = dt.gettext.gettext
dt.gettext.bindtextdomain("color_profile_manager", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -365,7 +363,7 @@ end
local script_data = {}
script_data.metadata = {
name = "color_profile_manager",
name = _("color profile manager"),
purpose = _("manage external darktable color profiles"),
author = "Bill Ferguson <wpferguson@gmail.com>",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/color_profile_manager"

View File

@@ -44,8 +44,6 @@ local gettext = dt.gettext.gettext
du.check_min_api_version("7.0.0", "copy_attach_detach_tags")
dt.gettext.bindtextdomain("copy_attach_detach_tags", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -55,7 +53,7 @@ end
local script_data = {}
script_data.metadata = {
name = "copy_attach_detach_tags",
name = _("copy attach detach tags"),
purpose = _("shortcuts to copy, paste, replace, or remove tags from images"),
author = "Christian Kanzian",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/copy_attach_detach_tags"

View File

@@ -39,8 +39,6 @@ du.check_min_api_version("7.0.0", "cr2hdr")
local gettext = darktable.gettext.gettext
darktable.gettext.bindtextdomain("cr2hdr", darktable.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -50,7 +48,7 @@ end
local script_data = {}
script_data.metadata = {
name = "cr2hdr",
name = _("cr2hdr"),
purpose = _("process Magic Lantern dual ISO images"),
author = "Till Theato <theato@ttill.de>",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/cr2hdr"

View File

@@ -60,8 +60,6 @@ du.check_min_api_version("7.0.0", MODULE)
local gettext = dt.gettext.gettext
dt.gettext.bindtextdomain("cycle_group_leader", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -73,7 +71,7 @@ end
local script_data = {}
script_data.metadata = {
name = "cycle_group_leader",
name = _("cycle group leader"),
purpose = _("change image group leader"),
author = "Bill Ferguson <wpferguson@gmail.com>",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/cycle_group_leader"
@@ -106,7 +104,7 @@ end
local function cycle_group_leader(image)
local group_images = image:get_group_members()
if #group_images < 2 then
hinter_msg(_("no images to cycle to in group"))
hinter_msg(_("no images to cycle through in group"))
return
else
local position = nil

View File

@@ -0,0 +1,486 @@
-- Route handlers. Each function receives a request table and returns:
-- status (int), body (string), content_type (string|nil), extra_headers (table|nil)
local json = require "json"
local auth = require "auth"
local db = require "db"
local preview = require "preview"
local config = require "config"
local modules = require "modules"
local socket = require "socket"
-- ── Refresh queue (IPC with the darktable bridge plugin) ──────────────────────
-- The bridge plugin running inside darktable polls this file and calls
-- image:drop_cache() + image:generate_cache() for each queued image ID.
--
-- We append atomically via a .tmp + rename idiom to avoid partial reads:
-- 1. Write IDs to <queue>.tmp.<pid>
-- 2. Append that file's contents to the main queue (or rename if queue absent)
-- In practice, for low-frequency refresh calls a simple append is fine because
-- the bridge reads and renames the queue file atomically.
local function enqueue_refresh(image_id)
local qf = config.refresh_queue
local f, ferr = io.open(qf, "a")
if not f then
return false, "cannot open refresh queue " .. qf .. ": " .. (ferr or "?")
end
f:write(tostring(image_id) .. "\n")
f:close()
return true
end
local function enqueue_export(image_id)
local qf = config.export_queue
local f, ferr = io.open(qf, "a")
if not f then
return false, "cannot open export queue " .. qf .. ": " .. (ferr or "?")
end
f:write(tostring(image_id) .. "\n")
f:close()
return true
end
local function export_path(image_id)
return config.export_dir .. "/" .. tostring(image_id) .. ".jpg"
end
local M = {}
-- ── Helpers ───────────────────────────────────────────────────────────────────
local function ok(data, ct)
return 200, json.encode(data), ct or "application/json; charset=utf-8"
end
local function err(status, code, detail)
local body = json.encode({ error = code, detail = detail })
return status, body, "application/json; charset=utf-8"
end
-- Parse application/x-www-form-urlencoded or application/json body.
local function parse_body(req)
local ct = (req.headers["content-type"] or ""):lower()
if ct:find("application/json", 1, true) then
return json.decode(req.body or "{}")
end
-- form-encoded
local fields = {}
for k, v in (req.body or ""):gmatch("([^&=]+)=([^&]*)") do
k = k:gsub("+", " "):gsub("%%(%x%x)", function(h) return string.char(tonumber(h,16)) end)
v = v:gsub("+", " "):gsub("%%(%x%x)", function(h) return string.char(tonumber(h,16)) end)
fields[k] = v
end
return fields
end
-- Authenticate the request and check an optional scope.
-- Returns: payload on success.
-- Returns: nil, status, body, ct on failure (caller does "return s,b,ct").
local function require_auth(req, scope)
local payload, aerr = auth.authenticate(req)
if not payload then
return nil, err(401, "unauthorized", aerr)
end
if scope and not auth.require_scope(payload, scope) then
return nil, err(403, "forbidden", "scope '" .. scope .. "' required")
end
return payload
end
-- ── Public endpoints ──────────────────────────────────────────────────────────
-- GET /health — liveness check, no auth required
function M.health(req)
return ok({ status = "ok", timestamp = os.time() })
end
-- ── OAuth 2.0 ─────────────────────────────────────────────────────────────────
-- POST /oauth/token
-- Accepts: application/x-www-form-urlencoded or application/json
-- Required fields: grant_type=client_credentials, client_id, client_secret
-- Optional: scope (space-separated, must be subset of client's scopes)
--
-- Returns: { access_token, token_type, expires_in, scope }
function M.oauth_token(req)
local fields = parse_body(req)
if not fields then
return err(400, "invalid_request", "could not parse request body")
end
if fields.grant_type ~= "client_credentials" then
return err(400, "unsupported_grant_type",
"only 'client_credentials' is supported")
end
-- Also accept Basic Auth header for client credentials
local client_id, client_secret = fields.client_id, fields.client_secret
if not client_id then
local basic = (req.headers["authorization"] or ""):match("^[Bb]asic%s+(.+)$")
if basic then
local decoded = require("base64").decode(basic)
client_id, client_secret = decoded:match("^([^:]+):(.+)$")
end
end
if not client_id or not client_secret then
return err(400, "invalid_request", "client_id and client_secret are required")
end
local ok_flag, client_scopes = auth.validate_client(client_id, client_secret)
if not ok_flag then
return err(401, "invalid_client", client_scopes)
end
-- Restrict to requested scopes if provided
local granted = client_scopes
if fields.scope and fields.scope ~= "" then
local requested = {}
for s in fields.scope:gmatch("%S+") do requested[s] = true end
granted = {}
for _, s in ipairs(client_scopes) do
if requested[s] then granted[#granted+1] = s end
end
if #granted == 0 then
return err(400, "invalid_scope", "none of the requested scopes are available")
end
end
local token = auth.issue_token(client_id, granted)
return ok({
access_token = token,
token_type = "Bearer",
expires_in = config.token_ttl,
scope = table.concat(granted, " "),
})
end
-- ── Images ────────────────────────────────────────────────────────────────────
-- GET /api/v1/images
-- Query params: rating_min, rating_max, rating, film_id, from, to,
-- color_label, is_raw, is_hdr, search, tag,
-- limit (1-500, default 50), offset, sort_by, sort_dir
function M.list_images(req)
local payload, s, b, ct = require_auth(req, "images:read")
if not payload then return s, b, ct end
local result = db.list_images(req.params)
return ok(result)
end
-- GET /api/v1/images/:id
function M.get_image(req)
local payload, s, b, ct = require_auth(req, "images:read")
if not payload then return s, b, ct end
local id = tonumber(req.path_params.id)
if not id then return err(400, "invalid_id", "id must be an integer") end
local img = db.get_image(id)
if not img then return err(404, "not_found", "image " .. id .. " not found") end
return ok(img)
end
-- ── Previews ──────────────────────────────────────────────────────────────────
-- GET /api/v1/images/:id/preview[?size=thumb|small|medium|large|xlarge|0-5]
-- Returns JPEG data directly (Content-Type: image/jpeg).
function M.get_preview(req)
local payload, s, b, ct = require_auth(req, "previews:read")
if not payload then return s, b, ct end
local id = tonumber(req.path_params.id)
if not id then return err(400, "invalid_id", "id must be an integer") end
local img = db.get_image(id)
if not img then return err(404, "not_found", "image " .. id .. " not found") end
local data, ct, redirect = preview.get(id, img.path, img.filename, req.params.size)
if data then
return 200, data, ct
end
if redirect then
-- Attempt to serve the local file directly rather than issuing a redirect,
-- since file:// URLs are not useful to HTTP clients.
local file_data = preview.read_local(redirect)
if file_data then
local ext = (img.filename or ""):match("%.([^%.]+)$") or ""
local mime = ext:lower():match("^png$") and "image/png" or "image/jpeg"
return 200, file_data, mime
end
end
-- No cached preview exists. Enqueue a full-pipeline thumbnail generation
-- so the bridge renders one; the client should retry after a few seconds.
enqueue_refresh(id)
return 202, json.encode({
status = "generating",
image_id = id,
retry_after = 4,
message = "thumbnail is being generated; retry in a few seconds",
}), "application/json; charset=utf-8", { ["Retry-After"] = "4" }
end
-- ── Tags ──────────────────────────────────────────────────────────────────────
-- GET /api/v1/tags[?search=&limit=&offset=]
function M.list_tags(req)
local payload, s, b, ct = require_auth(req, "tags:read")
if not payload then return s, b, ct end
local tags = db.list_tags(req.params)
return ok({ items = tags })
end
-- ── Film rolls ────────────────────────────────────────────────────────────────
-- GET /api/v1/films[?search=&limit=&offset=]
function M.list_films(req)
local payload, s, b, ct = require_auth(req, "films:read")
if not payload then return s, b, ct end
local films = db.list_films(req.params)
return ok({ items = films })
end
-- ── Cache refresh ─────────────────────────────────────────────────────────────
-- POST /api/v1/images/:id/refresh
-- Enqueues the image for thumbnail regeneration via the darktable bridge.
-- The bridge calls image:drop_cache() + image:generate_cache(true, …), which
-- re-renders through darktable's full processing pipeline so that development
-- settings (tone curves, exposure, colour grading, etc.) are reflected.
-- Requires scope: cache:refresh
function M.refresh_cache(req)
local payload, s, b, ct = require_auth(req, "cache:refresh")
if not payload then return s, b, ct end
local id = tonumber(req.path_params.id)
if not id then return err(400, "invalid_id", "id must be an integer") end
local img = db.get_image(id)
if not img then return err(404, "not_found", "image " .. id .. " not found") end
local queued, qerr = enqueue_refresh(id)
if not queued then
return err(500, "queue_error", qerr)
end
return 202, json.encode({
status = "accepted",
image_id = id,
filename = img.filename,
queue = config.refresh_queue,
message = "cache refresh enqueued; darktable bridge will re-render thumbnails shortly",
}), "application/json; charset=utf-8"
end
-- ── Development modules ───────────────────────────────────────────────────────
-- GET /api/v1/images/:id/modules
-- Returns every operation in the history stack (active entry per operation).
-- Response includes decoded params for known modules; base64 blob for others.
-- Requires scope: images:read
function M.list_modules(req)
local payload, s, b, ct = require_auth(req, "images:read")
if not payload then return s, b, ct end
local id = tonumber(req.path_params.id)
if not id then return err(400, "invalid_id", "id must be an integer") end
local img = db.get_image(id)
if not img then return err(404, "not_found", "image " .. id .. " not found") end
local mods = db.list_modules(id)
return ok({ image_id = id, items = mods })
end
-- GET /api/v1/images/:id/modules/:op
-- Returns the active history entry for one operation.
-- Requires scope: images:read
function M.get_module(req)
local payload, s, b, ct = require_auth(req, "images:read")
if not payload then return s, b, ct end
local id = tonumber(req.path_params.id)
local op = req.path_params.op
if not id then return err(400, "invalid_id", "id must be an integer") end
local img = db.get_image(id)
if not img then return err(404, "not_found", "image " .. id .. " not found") end
local mod = db.get_module(id, op)
if not mod then
return err(404, "module_not_found",
"operation '" .. op .. "' not in history for image " .. id)
end
return ok(mod)
end
-- PATCH /api/v1/images/:id/modules/:op
-- Body (JSON):
-- {
-- "enabled": true|false, -- optional: change enabled state
-- "params": { field: value, … } -- optional: named params for known modules
-- "params_b64": "<base64>" -- alternative: raw binary for unknown modules
-- }
-- Query param: ?preview=thumb|small|medium|large|xlarge
-- If given, waits up to 30 s for the thumbnail to update and returns the JPEG.
-- Otherwise returns 204 No Content.
-- Requires scope: cache:refresh
function M.patch_module(req)
local payload, s, b, ct = require_auth(req, "cache:refresh")
if not payload then return s, b, ct end
local id = tonumber(req.path_params.id)
local op = req.path_params.op
if not id then return err(400, "invalid_id", "id must be an integer") end
local img = db.get_image(id)
if not img then return err(404, "not_found", "image " .. id .. " not found") end
local mod = db.get_module(id, op)
if not mod then
return err(404, "module_not_found",
"operation '" .. op .. "' not in history for image " .. id)
end
-- Parse request body
local body = parse_body(req)
if not body then
return err(400, "invalid_body", "could not parse JSON body")
end
local new_enabled = body.enabled -- may be nil (leave unchanged)
local new_blob = nil
if body.params_b64 then
-- Caller provided raw base64 params (for unknown modules)
local ok_dec, decoded = pcall(require("base64").decode, body.params_b64)
if not ok_dec then
return err(400, "invalid_params_b64", "base64 decode failed")
end
new_blob = decoded
elseif body.params then
if mod.params then
local existing_blob = db.get_module_blob(id, op)
if not existing_blob then
return err(500, "db_error", "could not read existing params blob")
end
new_blob = modules.encode(op, mod.module_version, existing_blob, body.params)
if not new_blob then
return err(400, "unsupported_module",
"named-param editing is not supported for operation '" .. op ..
"'; use params_b64 with a raw binary blob instead")
end
else
-- Unknown module, caller must supply params_b64
return err(400, "params_required",
"operation '" .. op .. "' requires params_b64 (binary blob) for editing")
end
end
-- Persist to history table
local ok_write, werr = db.set_module_params(id, op, new_enabled, new_blob)
if not ok_write then
return err(500, "db_write_error", werr)
end
-- Enqueue a thumbnail refresh so the bridge re-renders with the new params.
enqueue_refresh(id)
-- Optional synchronous preview response
local preview_size = req.params.preview
if preview_size and preview_size ~= "" then
-- Read the thumbnail before the refresh so we can detect when it changes.
local old_data = preview.get(id, img.path, img.filename, preview_size)
local deadline = os.time() + 30
local new_data
while os.time() < deadline do
socket.select(nil, nil, 0.5)
local candidate = preview.get(id, img.path, img.filename, preview_size)
if candidate and candidate ~= old_data then
new_data = candidate
break
end
end
if new_data then
return 200, new_data, "image/jpeg"
end
-- Timeout — return what we have (may be stale) with a warning header
local fallback = preview.get(id, img.path, img.filename, preview_size)
if fallback then
return 200, fallback, "image/jpeg",
{ ["X-Preview-Warning"] = "regeneration-timeout; thumbnail may be stale" }
end
return err(504, "preview_timeout",
"thumbnail regeneration timed out after 30 s; retry the preview endpoint")
end
return 204, "", nil
end
-- ── Full-resolution export ────────────────────────────────────────────────────
-- POST /api/v1/images/:id/export
-- Removes any stale export file, enqueues the bridge job, returns 202.
-- The client should poll GET /full until it receives a 200.
-- Requires scope: cache:refresh
function M.trigger_export(req)
local payload, s, b, ct = require_auth(req, "cache:refresh")
if not payload then return s, b, ct end
local id = tonumber(req.path_params.id)
if not id then return err(400, "invalid_id", "id must be an integer") end
local img = db.get_image(id)
if not img then return err(404, "not_found", "image " .. id .. " not found") end
-- Remove stale export so the client can detect when the fresh one lands.
os.remove(export_path(id))
local queued, qerr = enqueue_export(id)
if not queued then return err(500, "queue_error", qerr) end
return 202, json.encode({
status = "accepted",
image_id = id,
filename = img.filename,
message = "export enqueued; poll GET /api/v1/images/" .. id .. "/full",
}), "application/json; charset=utf-8"
end
-- GET /api/v1/images/:id/full
-- Serves the full-resolution JPEG previously exported via POST /export.
-- If no export exists yet, enqueues one and returns 202 so the client can retry.
-- Requires scope: previews:read
function M.get_full(req)
local payload, s, b, ct = require_auth(req, "previews:read")
if not payload then return s, b, ct end
local id = tonumber(req.path_params.id)
if not id then return err(400, "invalid_id", "id must be an integer") end
local img = db.get_image(id)
if not img then return err(404, "not_found", "image " .. id .. " not found") end
local path = export_path(id)
local f = io.open(path, "rb")
if f then
local data = f:read("*a"); f:close()
local stem = (img.filename or "export"):match("^(.+)%.[^.]+$") or (img.filename or "export")
local disposition = string.format('attachment; filename="%s.jpg"', stem)
return 200, data, "image/jpeg", { ["Content-Disposition"] = disposition }
end
-- Not yet exported — enqueue and tell the client to retry.
local queued, qerr = enqueue_export(id)
if not queued then
return err(500, "queue_error", qerr)
end
return 202, json.encode({
status = "queued",
image_id = id,
filename = img.filename,
retry_after = 5,
message = "export enqueued; retry in a few seconds",
}), "application/json; charset=utf-8", { ["Retry-After"] = "5" }
end
return M

View File

@@ -0,0 +1,96 @@
-- OAuth 2.0 Client Credentials grant + HS256 JWT
-- Requires: LuaJIT FFI with libcrypto (OpenSSL)
local ffi = require "ffi"
local base64 = require "base64"
local json = require "json"
local config = require "config"
-- ── OpenSSL HMAC-SHA256 via FFI ───────────────────────────────────────────────
ffi.cdef[[
void *EVP_sha256(void);
unsigned char *HMAC(const void *evp_md, const void *key, int key_len,
const unsigned char *data, size_t data_len,
unsigned char *md, unsigned int *md_len);
]]
-- libcrypto ships as part of OpenSSL; path varies by distro but the soname is standard
local crypto = ffi.load("crypto")
local function hmac_sha256(key, data)
local digest = ffi.new("unsigned char[32]")
local dlen = ffi.new("unsigned int[1]")
crypto.HMAC(crypto.EVP_sha256(), key, #key, data, #data, digest, dlen)
return ffi.string(digest, 32)
end
-- ── JWT helpers ───────────────────────────────────────────────────────────────
local HEADER_B64 = base64.urlencode(json.encode({ alg = "HS256", typ = "JWT" }))
local function jwt_sign(payload_tbl)
local payload = base64.urlencode(json.encode(payload_tbl))
local signing = HEADER_B64 .. "." .. payload
local sig = base64.urlencode(hmac_sha256(config.jwt_secret, signing))
return signing .. "." .. sig
end
local function jwt_verify(token)
local h, p, s = token:match("^([^.]+)%.([^.]+)%.([^.]+)$")
if not h then return nil, "malformed token" end
local expected = base64.urlencode(hmac_sha256(config.jwt_secret, h .. "." .. p))
if s ~= expected then return nil, "invalid signature" end
local payload, err = json.decode(base64.urldecode(p))
if not payload then return nil, "invalid payload: " .. (err or "?") end
if os.time() > (payload.exp or 0) then return nil, "token expired" end
return payload
end
-- ── Public API ────────────────────────────────────────────────────────────────
local M = {}
-- Validate client_id / client_secret pair; returns true + scopes or false + error.
function M.validate_client(client_id, client_secret)
local client = config.clients[client_id]
if not client then return false, "unknown client" end
if client.secret ~= client_secret then return false, "invalid client secret" end
return true, client.scopes
end
-- Issue an access token for a validated client.
function M.issue_token(client_id, scopes)
local now = os.time()
return jwt_sign({
iss = "darktable-api",
sub = client_id,
scope = table.concat(scopes, " "),
iat = now,
exp = now + config.token_ttl,
})
end
-- Extract and verify a Bearer token from a request.
-- Returns payload table on success, or nil + error string on failure.
function M.authenticate(req)
local auth = (req.headers or {})["authorization"] or ""
local token = auth:match("^[Bb]earer%s+(.+)$")
if not token then return nil, "missing Bearer token" end
return jwt_verify(token)
end
-- Check that the authenticated payload contains a required scope.
function M.require_scope(payload, scope)
if not payload then return false end
local scopes = payload.scope or ""
for s in scopes:gmatch("%S+") do
if s == scope then return true end
end
return false
end
return M

View File

@@ -0,0 +1,52 @@
-- Base64 and Base64url encode/decode (pure Lua, no dependencies)
local M = {}
local CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local LOOKUP = {}
for i = 1, #CHARS do LOOKUP[CHARS:sub(i,i)] = i - 1 end
function M.encode(s)
local out = {}
for i = 1, #s, 3 do
local a, b, c = s:byte(i, i + 2)
b, c = b or 0, c or 0
local n = a * 65536 + b * 256 + c
out[#out+1] = CHARS:sub(math.floor(n / 262144) % 64 + 1, math.floor(n / 262144) % 64 + 1)
out[#out+1] = CHARS:sub(math.floor(n / 4096) % 64 + 1, math.floor(n / 4096) % 64 + 1)
out[#out+1] = CHARS:sub(math.floor(n / 64) % 64 + 1, math.floor(n / 64) % 64 + 1)
out[#out+1] = CHARS:sub( n % 64 + 1, n % 64 + 1)
end
local rem = #s % 3
if rem == 1 then out[#out-1], out[#out] = "=", "=" end
if rem == 2 then out[#out] = "=" end
return table.concat(out)
end
function M.decode(s)
s = s:gsub("[^A-Za-z0-9+/=]", "")
local out = {}
for i = 1, #s, 4 do
local a = LOOKUP[s:sub(i, i )] or 0
local b = LOOKUP[s:sub(i+1, i+1)] or 0
local c = LOOKUP[s:sub(i+2, i+2)] or 0
local d = LOOKUP[s:sub(i+3, i+3)] or 0
local n = a * 262144 + b * 4096 + c * 64 + d
out[#out+1] = string.char(math.floor(n / 65536))
if s:sub(i+2, i+2) ~= "=" then out[#out+1] = string.char(math.floor(n / 256) % 256) end
if s:sub(i+3, i+3) ~= "=" then out[#out+1] = string.char(n % 256) end
end
return table.concat(out)
end
-- URL-safe Base64 (RFC 4648 §5): replace +/ with -_ and strip padding
function M.urlencode(s)
return M.encode(s):gsub("+", "-"):gsub("/", "_"):gsub("=", "")
end
function M.urldecode(s)
s = s:gsub("-", "+"):gsub("_", "/")
local pad = (4 - #s % 4) % 4
return M.decode(s .. string.rep("=", pad))
end
return M

View File

@@ -0,0 +1,33 @@
meta {
name: Health Check
type: http
seq: 1
}
get {
url: {{baseUrl}}/health
body: none
auth: none
}
tests {
test("returns 200", function() {
expect(res.status).to.equal(200);
});
test("status is ok", function() {
expect(res.body.status).to.equal("ok");
});
test("has timestamp", function() {
expect(res.body.timestamp).to.be.a("number");
expect(res.body.timestamp).to.be.above(0);
});
}
docs {
## Health Check
Liveness probe — no authentication required.
Returns `{"status":"ok","timestamp":<unix>}`.
}

View File

@@ -0,0 +1,33 @@
meta {
name: Get Token (Bad Secret)
type: http
seq: 2
}
post {
url: {{baseUrl}}/oauth/token
body: form-urlencoded
auth: none
}
body:form-urlencoded {
grant_type: client_credentials
client_id: {{clientId}}
client_secret: wrong-secret
}
tests {
test("returns 401", function() {
expect(res.status).to.equal(401);
});
test("error is invalid_client", function() {
expect(res.body.error).to.equal("invalid_client");
});
}
docs {
## Negative test — wrong client secret
Verifies the server rejects bad credentials with 401.
}

View File

@@ -0,0 +1,64 @@
meta {
name: Get Token
type: http
seq: 1
}
post {
url: {{baseUrl}}/oauth/token
body: form-urlencoded
auth: none
}
body:form-urlencoded {
grant_type: client_credentials
client_id: {{clientId}}
client_secret: {{clientSecret}}
}
script:post-response {
if (res.status === 200) {
bru.setVar("accessToken", res.body.access_token);
console.log("Token stored. Expires in", res.body.expires_in, "seconds.");
console.log("Scopes:", res.body.scope);
} else {
console.error("Token request failed:", res.status, res.body);
}
}
tests {
test("returns 200", function() {
expect(res.status).to.equal(200);
});
test("has access_token", function() {
expect(res.body.access_token).to.be.a("string");
expect(res.body.access_token.length).to.be.above(0);
});
test("token_type is Bearer", function() {
expect(res.body.token_type).to.equal("Bearer");
});
test("scope includes images:read", function() {
expect(res.body.scope).to.include("images:read");
});
test("scope includes cache:refresh", function() {
expect(res.body.scope).to.include("cache:refresh");
});
test("expires_in is 3600", function() {
expect(res.body.expires_in).to.equal(3600);
});
}
docs {
## OAuth 2.0 — Client Credentials Grant
Exchanges `client_id` + `client_secret` for a short-lived Bearer JWT.
The post-response script stores the token in `{{accessToken}}` so all
other requests pick it up automatically.
Run this request first before calling any protected endpoint.
}

View File

@@ -0,0 +1,6 @@
{
"version": "1",
"name": "Darktable API",
"type": "collection",
"ignore": []
}

View File

@@ -0,0 +1,12 @@
vars {
baseUrl: http://127.0.0.1:7645
clientId: darktable-client
clientSecret: changeme
accessToken:
imageId: 1
}
vars:secret [
clientSecret,
accessToken
]

View File

@@ -0,0 +1,12 @@
vars {
baseUrl: http://192.168.1.134:7645
clientId: darktable-client
clientSecret: changeme
accessToken:
imageId: 1
}
vars:secret [
clientSecret,
accessToken
]

View File

@@ -0,0 +1,54 @@
meta {
name: List Films
type: http
seq: 1
}
get {
url: {{baseUrl}}/api/v1/films
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
params:query {
limit: 50
offset: 0
~search: 2018
}
tests {
test("returns 200", function() {
expect(res.status).to.equal(200);
});
test("has items array", function() {
expect(res.body.items).to.be.an("array");
});
test("each film has id, folder, image_count", function() {
res.body.items.forEach(function(film) {
expect(film.id).to.be.a("number");
expect(film.folder).to.be.a("string");
expect(film.image_count).to.be.a("number");
});
});
test("image counts are positive", function() {
res.body.items.forEach(function(film) {
expect(film.image_count).to.be.above(0);
});
});
}
docs {
## List Films
Returns all film rolls (import folders) with their image counts, sorted by folder path.
Use `?search=` to filter by folder path substring.
Use `?limit` and `?offset` for pagination (max 2000 per page).
}

View File

@@ -0,0 +1,52 @@
meta {
name: Export Image
type: http
seq: 6
}
post {
url: {{baseUrl}}/api/v1/images/{{imageId}}/export
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
tests {
test("returns 202 Accepted", function() {
expect(res.status).to.equal(202);
});
test("status is accepted", function() {
expect(res.body.status).to.equal("accepted");
});
test("image_id matches request", function() {
expect(res.body.image_id).to.equal(Number(bru.getVar("imageId")));
});
test("has filename", function() {
expect(res.body.filename).to.be.a("string");
});
test("has export path", function() {
expect(res.body.export).to.be.a("string");
});
}
docs {
## Export Image (Full Resolution)
Enqueues a full-resolution JPEG export processed through darktable's complete
development pipeline — tone curves, exposure, colour grading, noise reduction,
and all other active modules are applied.
Returns **202 Accepted** immediately. The darktable bridge plugin writes the
result to the path shown in `export` (default: `/tmp/dt-api-exports/<id>.jpg`).
Poll **GET /api/v1/images/:id/full** to retrieve the image once ready.
Requires scope: `cache:refresh`
}

View File

@@ -0,0 +1,57 @@
meta {
name: Get Full Image
type: http
seq: 7
}
get {
url: {{baseUrl}}/api/v1/images/{{imageId}}/full
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
tests {
test("returns 200 or 202", function() {
expect([200, 202]).to.include(res.status);
});
test("if 200, content-type is image/jpeg", function() {
if (res.status === 200) {
expect(res.headers["content-type"]).to.equal("image/jpeg");
}
});
test("if 200, body is non-empty", function() {
if (res.status === 200) {
expect(res.body.length).to.be.above(0);
}
});
test("if 202, export was queued", function() {
if (res.status === 202) {
expect(res.body.status).to.equal("queued");
expect(res.body.retry_after).to.be.a("number");
}
});
}
docs {
## Get Full-Resolution Image
Returns the full-resolution JPEG developed through darktable's complete
processing pipeline (all active development modules applied).
### Workflow
1. POST `/api/v1/images/:id/export` — enqueue the export (returns 202)
2. Wait ~2–10 s (depending on complexity and hardware)
3. GET `/api/v1/images/:id/full` — download the result (200 with JPEG body)
If no export exists yet this endpoint auto-enqueues one and returns **202**
with a `Retry-After: 5` header. Keep retrying until you receive **200**.
Requires scope: `previews:read`
}

View File

@@ -0,0 +1,31 @@
meta {
name: Get Image (Not Found)
type: http
seq: 3
}
get {
url: {{baseUrl}}/api/v1/images/999999
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
tests {
test("returns 404", function() {
expect(res.status).to.equal(404);
});
test("error is not_found", function() {
expect(res.body.error).to.equal("not_found");
});
}
docs {
## Get Image — Not Found
Verifies a non-existent image ID returns 404.
}

View File

@@ -0,0 +1,72 @@
meta {
name: Get Image
type: http
seq: 2
}
get {
url: {{baseUrl}}/api/v1/images/{{imageId}}
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
script:post-response {
if (res.status === 200) {
// Store this image's ID for use by preview / refresh requests
bru.setVar("imageId", res.body.id);
}
}
tests {
test("returns 200", function() {
expect(res.status).to.equal(200);
});
test("has correct id", function() {
expect(res.body.id).to.equal(Number(bru.getVar("imageId")));
});
test("has filename", function() {
expect(res.body.filename).to.be.a("string");
expect(res.body.filename.length).to.be.above(0);
});
test("has path", function() {
expect(res.body.path).to.be.a("string");
});
test("has rating in valid range", function() {
expect(res.body.rating).to.be.at.least(-1);
expect(res.body.rating).to.be.at.most(5);
});
test("has tags array", function() {
expect(res.body.tags).to.be.an("array");
});
test("has color_labels array", function() {
expect(res.body.color_labels).to.be.an("array");
});
test("has metadata object", function() {
expect(res.body.metadata).to.be.an("object");
});
test("has boolean flags", function() {
expect(res.body.is_raw).to.be.a("boolean");
expect(res.body.is_hdr).to.be.a("boolean");
expect(res.body.is_ldr).to.be.a("boolean");
});
}
docs {
## Get Image
Returns full detail for a single image including tags, color labels, and metadata.
Set `{{imageId}}` in the environment (or pick up from "List Images" run).
}

View File

@@ -0,0 +1,67 @@
meta {
name: Get Preview
type: http
seq: 4
}
get {
url: {{baseUrl}}/api/v1/images/{{imageId}}/preview
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
params:query {
size: small
~size: medium
~size: large
~size: thumb
~size: xlarge
}
tests {
test("returns 200 or 404", function() {
expect([200, 404]).to.include(res.status);
});
test("if 200, content-type is image/jpeg or image/png", function() {
if (res.status === 200) {
expect(res.headers["content-type"]).to.match(/image\/(jpeg|png)/);
}
});
test("if 200, body is non-empty", function() {
if (res.status === 200) {
expect(res.body.length).to.be.above(0);
}
});
test("if 404, no_preview error returned", function() {
if (res.status === 404) {
expect(res.body.error).to.equal("no_preview");
}
});
}
docs {
## Get Preview
Returns a JPEG thumbnail from darktable's mipmap cache.
### Size options
| Value | Approx. long edge |
|---|---|
| `thumb` | ~135 px |
| `small` | ~270 px |
| `medium` | ~540 px (default) |
| `large` | ~1080 px |
| `xlarge` | ~2160 px |
| `0`–`5` | Raw mip level |
Only one `size` param is active at a time — uncomment the desired one above.
Falls back to lower mip levels automatically if the requested size isn't cached.
Returns 404 if no cached thumbnail exists at any level.
}

View File

@@ -0,0 +1,27 @@
meta {
name: List Images (No Auth)
type: http
seq: 6
}
get {
url: {{baseUrl}}/api/v1/images
body: none
auth: none
}
tests {
test("returns 401 without token", function() {
expect(res.status).to.equal(401);
});
test("error is unauthorized", function() {
expect(res.body.error).to.equal("unauthorized");
});
}
docs {
## List Images — No Auth
Verifies that protected endpoints return 401 when no Bearer token is supplied.
}

View File

@@ -0,0 +1,87 @@
meta {
name: List Images
type: http
seq: 1
}
get {
url: {{baseUrl}}/api/v1/images
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
params:query {
limit: 10
offset: 0
sort_by: datetime_taken
sort_dir: desc
~rating_min: 1
~rating_max: 5
~rating: 3
~film_id: 1
~from: 2018-01-01
~to: 2018-12-31
~color_label: red
~is_raw: true
~is_hdr: false
~search: 500mm
~tag: realestate
}
tests {
test("returns 200", function() {
expect(res.status).to.equal(200);
});
test("has items array", function() {
expect(res.body.items).to.be.an("array");
});
test("has total count", function() {
expect(res.body.total).to.be.a("number");
expect(res.body.total).to.be.above(0);
});
test("has limit and offset", function() {
expect(res.body.limit).to.equal(10);
expect(res.body.offset).to.equal(0);
});
test("each image has required fields", function() {
res.body.items.forEach(function(img) {
expect(img.id).to.be.a("number");
expect(img.filename).to.be.a("string");
expect(img.path).to.be.a("string");
expect(img.rating).to.be.a("number");
});
});
}
docs {
## List Images
Returns a paginated list of images from the darktable catalog.
### Active query params (remove `~` prefix to enable)
| Param | Type | Description |
|---|---|---|
| `limit` | int | Results per page (max 500, default 50) |
| `offset` | int | Pagination offset |
| `sort_by` | string | `id`, `filename`, `datetime_taken`, `rating`, `focal_length`, `iso` |
| `sort_dir` | string | `asc` or `desc` |
| `rating_min` | int | Minimum star rating (−1=rejected, 0=unrated, 1–5=stars) |
| `rating_max` | int | Maximum star rating |
| `rating` | int | Exact rating |
| `film_id` | int | Filter by film roll ID |
| `from` | date | Earliest capture date (YYYY-MM-DD) |
| `to` | date | Latest capture date (YYYY-MM-DD) |
| `color_label` | string | `red`, `yellow`, `green`, `blue`, `purple` |
| `is_raw` | bool | `true` / `false` |
| `is_hdr` | bool | `true` / `false` |
| `search` | string | Free-text search in filename, maker, model, lens |
| `tag` | string | Filter by exact tag name |
}

View File

@@ -0,0 +1,49 @@
meta {
name: Refresh Cache
type: http
seq: 5
}
post {
url: {{baseUrl}}/api/v1/images/{{imageId}}/refresh
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
tests {
test("returns 202 Accepted", function() {
expect(res.status).to.equal(202);
});
test("status is accepted", function() {
expect(res.body.status).to.equal("accepted");
});
test("image_id matches request", function() {
expect(res.body.image_id).to.equal(Number(bru.getVar("imageId")));
});
test("has filename", function() {
expect(res.body.filename).to.be.a("string");
});
test("has queue path", function() {
expect(res.body.queue).to.be.a("string");
});
}
docs {
## Refresh Cache
Enqueues the image for thumbnail regeneration inside the running darktable instance.
Returns **202 Accepted** immediately. The darktable bridge plugin
(`darktable-api-bridge.lua`) polls `/tmp/dt-api-refresh.queue` every second
and calls `image:drop_cache()` + `image:generate_cache()` when it finds this ID.
Requires scope: `cache:refresh`
}

View File

@@ -0,0 +1,31 @@
meta {
name: Get Module (Not Found)
type: http
seq: 4
}
get {
url: {{baseUrl}}/api/v1/images/{{imageId}}/modules/nonexistent_module
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
tests {
test("returns 404", function() {
expect(res.status).to.equal(404);
});
test("error is module_not_found", function() {
expect(res.body.error).to.equal("module_not_found");
});
}
docs {
## Get Module — Not Found
Verifies 404 is returned for an operation that does not exist in the image history.
}

View File

@@ -0,0 +1,55 @@
meta {
name: Get Module
type: http
seq: 2
}
get {
url: {{baseUrl}}/api/v1/images/{{imageId}}/modules/filmicrgb
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
tests {
test("returns 200", function() {
expect(res.status).to.equal(200);
});
test("operation is filmicrgb", function() {
expect(res.body.operation).to.equal("filmicrgb");
});
test("has enabled flag", function() {
expect(res.body.enabled).to.be.a("boolean");
});
test("has decoded params", function() {
expect(res.body.params).to.be.an("object");
expect(res.body.params.grey_point_source).to.be.a("number");
expect(res.body.params.black_point_source).to.be.a("number");
expect(res.body.params.white_point_source).to.be.a("number");
expect(res.body.params.contrast).to.be.a("number");
expect(res.body.params.saturation).to.be.a("number");
});
// Save for the Patch Module test
bru.setVar("filmicGreyPointSource", res.body.params.grey_point_source);
}
docs {
## Get Module
Returns the active history entry for the `filmicrgb` module.
Change the URL path segment to inspect a different module, e.g.:
- `.../modules/exposure`
- `.../modules/temperature`
- `.../modules/highlights`
- `.../modules/channelmixerrgb`
Returns 404 if the operation has never been added to this image's history.
}

View File

@@ -0,0 +1,79 @@
meta {
name: List Modules
type: http
seq: 1
}
get {
url: {{baseUrl}}/api/v1/images/{{imageId}}/modules
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
tests {
test("returns 200", function() {
expect(res.status).to.equal(200);
});
test("has image_id", function() {
expect(res.body.image_id).to.equal(Number(bru.getVar("imageId")));
});
test("has items array", function() {
expect(res.body.items).to.be.an("array");
expect(res.body.items.length).to.be.above(0);
});
test("each item has required fields", function() {
res.body.items.forEach(function(m) {
expect(m.operation).to.be.a("string");
expect(m.enabled).to.be.a("boolean");
expect(m.module_version).to.be.a("number");
expect(m.history_num).to.be.a("number");
});
});
test("known modules have decoded params", function() {
var known = res.body.items.filter(function(m) {
return ["exposure","temperature","filmicrgb","highlights",
"channelmixerrgb","flip","gamma","rawprepare","demosaic"].indexOf(m.operation) >= 0;
});
known.forEach(function(m) {
expect(m.params).to.not.be.null;
expect(typeof m.params).to.equal("object");
});
});
test("unknown modules have params_b64", function() {
var unknown = res.body.items.filter(function(m) { return m.params === undefined || m.params === null; });
unknown.forEach(function(m) {
expect(m.params_b64).to.be.a("string");
});
});
// Save the first module op for subsequent tests
bru.setVar("moduleOp", res.body.items[0].operation);
}
docs {
## List Modules
Returns all active development modules (history stack entries) for the image.
Each item includes:
- `operation` – module name (e.g. `"exposure"`, `"filmicrgb"`)
- `enabled` – whether the module is currently active in the pipeline
- `module_version` – params struct version stored in the database
- `multi_name` – user label for multi-instance modules (empty for single-instance)
- `history_num` – position in the history stack (higher = more recent)
- `params` – decoded named parameters (for supported modules)
- `params_b64` – base64-encoded raw binary blob (for unsupported modules)
### Supported modules (params decoded)
`exposure`, `temperature`, `filmicrgb`, `channelmixerrgb`, `highlights`,
`flip`, `gamma`, `rawprepare`, `demosaic`
}

View File

@@ -0,0 +1,89 @@
meta {
name: Patch Module
type: http
seq: 3
}
patch {
url: {{baseUrl}}/api/v1/images/{{imageId}}/modules/filmicrgb
body: json
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
params:query {
~preview: medium
}
body:json {
{
"enabled": true,
"params": {
"grey_point_source": 18.45,
"white_point_source": 4.0,
"black_point_source": -8.0,
"contrast": 1.0
}
}
}
tests {
test("returns 204 (no preview) or 200 (with preview)", function() {
expect([200, 204]).to.include(res.status);
});
test("if 200, content-type is image/jpeg", function() {
if (res.status === 200) {
expect(res.headers["content-type"]).to.equal("image/jpeg");
}
});
}
docs {
## Patch Module
Update one or more parameters on a development module and optionally get a
preview image back showing the result.
### Body fields
- `enabled` (boolean, optional) – enable or disable the module
- `params` (object, optional) – named parameter overrides for known modules
- `params_b64` (string, optional) – raw base64 binary blob for unknown modules
(mutually exclusive with `params`)
Only fields present in `params` are changed; all others keep their current values.
### Preview query param
Add `?preview=small|medium|large|xlarge` to receive a JPEG thumbnail (200)
instead of empty 204. The server waits up to 30 s for the thumbnail to
regenerate before returning. Without this param the endpoint returns
**204 No Content** immediately (the bridge regenerates async in ~1-3 s).
### filmicrgb key parameters
| Field | Description | Default |
|---|---|---|
| `grey_point_source` | middle-grey exposure % | 18.45 |
| `black_point_source` | black relative exposure EV | -8.0 |
| `white_point_source` | white relative exposure EV | 4.0 |
| `contrast` | S-curve contrast | 1.0 |
| `saturation` | extreme-luminance saturation | 0.0 |
| `balance` | shadows ↔ highlights balance | 0.0 |
| `auto_hardness` | auto-adjust curve hardness | true |
### exposure key parameters
| Field | Description |
|---|---|
| `exposure` | exposure compensation in EV |
| `black` | black level correction |
| `mode` | 0=manual, 1=deflicker |
### temperature key parameters
| Field | Description |
|---|---|
| `red` | red channel multiplier |
| `green` | green channel multiplier |
| `blue` | blue channel multiplier |
}

View File

@@ -0,0 +1,54 @@
meta {
name: List Tags
type: http
seq: 1
}
get {
url: {{baseUrl}}/api/v1/tags
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
params:query {
limit: 50
offset: 0
~search: darktable
}
tests {
test("returns 200", function() {
expect(res.status).to.equal(200);
});
test("has items array", function() {
expect(res.body.items).to.be.an("array");
});
test("each tag has id, name, image_count", function() {
res.body.items.forEach(function(tag) {
expect(tag.id).to.be.a("number");
expect(tag.name).to.be.a("string");
expect(tag.image_count).to.be.a("number");
});
});
test("tags are sorted alphabetically", function() {
var names = res.body.items.map(function(t) { return t.name; });
var sorted = names.slice().sort();
expect(names).to.deep.equal(sorted);
});
}
docs {
## List Tags
Returns all tags in the catalog with their image counts, sorted alphabetically.
Use `?search=keyword` to filter by tag name prefix/substring.
Use `?limit` and `?offset` for pagination (max 2000 per page).
}

View File

@@ -0,0 +1,38 @@
local M = {}
local home = os.getenv("HOME") or "/root"
M.host = os.getenv("DT_API_HOST") or "127.0.0.1"
M.port = tonumber(os.getenv("DT_API_PORT")) or 7645
-- Darktable paths
M.library_db = os.getenv("DT_LIBRARY") or (home .. "/.config/darktable/library.db")
M.data_db = os.getenv("DT_DATA_DB") or (home .. "/.config/darktable/data.db")
M.cache_dir = os.getenv("DT_CACHE_DIR") or (home .. "/.cache/darktable")
-- JWT / OAuth
M.jwt_secret = os.getenv("DT_JWT_SECRET") or "change-this-secret-in-production"
M.token_ttl = tonumber(os.getenv("DT_TOKEN_TTL")) or 3600
-- OAuth 2.0 clients table: client_id -> { secret, scopes[] }
-- Additional clients can be registered by adding entries here or setting
-- DT_CLIENT_ID / DT_CLIENT_SECRET environment variables.
M.clients = {}
local default_id = os.getenv("DT_CLIENT_ID") or "darktable-client"
local default_secret = os.getenv("DT_CLIENT_SECRET") or "changeme"
M.clients[default_id] = {
secret = default_secret,
scopes = { "images:read", "previews:read", "tags:read", "films:read", "cache:refresh" },
}
-- Queue file the bridge plugin watches for cache-refresh requests.
M.refresh_queue = os.getenv("DT_REFRESH_QUEUE") or "/tmp/dt-api-refresh.queue"
-- Queue file the bridge plugin watches for full-resolution export requests.
M.export_queue = os.getenv("DT_EXPORT_QUEUE") or "/tmp/dt-api-export.queue"
-- Directory where the bridge writes full-resolution JPEG exports.
-- Served by GET /api/v1/images/:id/full.
M.export_dir = os.getenv("DT_EXPORT_DIR") or "/tmp/dt-api-exports"
return M

View File

@@ -0,0 +1,302 @@
--[[
darktable-api-bridge.lua
Companion plugin for the darktable REST API server.
Runs inside a live darktable instance and watches two queue files written by
the standalone LuaJIT server.
Refresh queue (DT_REFRESH_QUEUE, default /tmp/dt-api-refresh.queue)
One image ID per line. The bridge calls image:drop_cache() +
image:generate_cache(true, …) so that darktable re-renders thumbnails
through its full processing pipeline, reflecting tone curves, exposure,
colour grading, and all other development settings.
Export queue (DT_EXPORT_QUEUE, default /tmp/dt-api-export.queue)
One image ID per line. The bridge exports a full-resolution JPEG via
darktable's rendering pipeline to DT_EXPORT_DIR/<id>.jpg.
Install: add the following line to ~/.config/darktable/luarc
require "contrib/darktable-api/darktable-api-bridge"
Or let the startup script (start-with-api.sh) do it automatically.
--]]
local dt = require "darktable"
local du = require "lib/dtutils"
du.check_min_api_version("7.0.0", "darktable-api-bridge")
local MODULE = "darktable-api-bridge"
-- ── Configuration ─────────────────────────────────────────────────────────────
-- Must match config.refresh_queue / export_queue / export_dir in config.lua.
local QUEUE_FILE = os.getenv("DT_REFRESH_QUEUE") or "/tmp/dt-api-refresh.queue"
local EXPORT_QUEUE = os.getenv("DT_EXPORT_QUEUE") or "/tmp/dt-api-export.queue"
local EXPORT_DIR = os.getenv("DT_EXPORT_DIR") or "/tmp/dt-api-exports"
local POLL_MS = 1000 -- poll interval in milliseconds
-- Mipmap range to regenerate: 0 = smallest thumbnail, 4 ≈ 1080 px.
local MIP_MIN = 0
local MIP_MAX = 4
-- JPEG quality used for full-resolution exports.
local EXPORT_QUALITY = tonumber(os.getenv("DT_EXPORT_QUALITY")) or 95
-- ── Logging ───────────────────────────────────────────────────────────────────
local function log(msg)
dt.print_log("[" .. MODULE .. "] " .. msg)
end
local function notify(msg)
dt.print("[" .. MODULE .. "] " .. msg)
log(msg)
end
-- ── Image lookup ──────────────────────────────────────────────────────────────
-- Build a fast id→image map from dt.database.
-- dt.database is a table-like object; iterating it is O(n) but fast enough
-- for typical library sizes (thousands of images).
local function build_id_map()
local map = {}
for _, img in ipairs(dt.database) do
map[img.id] = img
end
return map
end
-- ── Queue file handling ───────────────────────────────────────────────────────
-- Atomically claim the queue file by renaming it, then read all IDs from it.
-- Returns a set table { [id] = true } of unique image IDs, or {} if none.
local function drain_queue()
local processing = QUEUE_FILE .. ".processing"
-- Rename is atomic on POSIX filesystems: the server's append-only writes
-- will start a fresh QUEUE_FILE after we claim this one.
local ok = os.rename(QUEUE_FILE, processing)
if not ok then
return {} -- queue file doesn't exist yet
end
local ids = {}
local f = io.open(processing, "r")
if f then
for line in f:lines() do
local id = tonumber(line)
if id then ids[id] = true end
end
f:close()
os.remove(processing)
end
return ids
end
-- ── Mipmap directory ─────────────────────────────────────────────────────────
-- Darktable's mipmap_cache->cachedir is not directly accessible from Lua, so
-- we replicate the naming: SHA1 hex of realpath(library.db) + ".d".
-- We compute it once and cache it here.
local _mipmap_cachedir
local function mipmap_cachedir()
if _mipmap_cachedir then return _mipmap_cachedir end
local cache_dir = os.getenv("DT_CACHE_DIR")
if not cache_dir then
local home = os.getenv("HOME") or "~"
cache_dir = home .. "/.cache/darktable"
end
-- There is exactly one mipmaps-<sha1>.d directory per library.
-- Find it by globbing rather than recomputing the SHA1.
local h = io.popen(string.format("ls -d %q/mipmaps-*.d 2>/dev/null | head -1", cache_dir))
local found = h and h:read("*l"); if h then h:close() end
if found and #found > 0 then
-- Strip trailing ".d" to get the base prefix used in file paths.
_mipmap_cachedir = found:match("^(.+)%.d$") or found
log("mipmap cachedir: " .. _mipmap_cachedir .. ".d")
return _mipmap_cachedir
end
log("WARNING: could not find mipmap cache directory under " .. cache_dir)
return nil
end
-- Delete all mipmap disk files for an image so generate_cache regenerates them.
local function delete_mipmap_files(imgid)
local base = mipmap_cachedir()
if not base then return end
for level = 0, 8 do
local path = string.format("%s.d/%d/%d.jpg", base, level, imgid)
os.remove(path)
end
end
-- ── Cache regeneration ────────────────────────────────────────────────────────
local function refresh_image(img)
local label = string.format("id=%d %s/%s", img.id, img.path, img.filename)
log("refreshing " .. label)
-- Remove disk mipmap files first so generate_cache doesn't skip any level.
-- drop_cache() only clears the in-memory entry; existing disk files would
-- cause generate_cache to skip those levels (it guards with dt_util_test_image_file).
delete_mipmap_files(img.id)
img:drop_cache()
-- create_dirs=true ensures the per-level directories exist.
img:generate_cache(true, MIP_MIN, MIP_MAX)
log("done " .. label)
end
local function export_image(img, dest_path)
local label = string.format("id=%d → %s", img.id, dest_path)
log("exporting " .. label)
local fmt = dt.new_format("jpeg")
fmt.quality = EXPORT_QUALITY
-- max_width/max_height = 0 means no scaling: full resolution.
fmt.max_width = 0
fmt.max_height = 0
local export_err = fmt:write_image(img, dest_path)
if export_err then
error("export failed: " .. tostring(export_err))
end
log("export done " .. label)
end
-- Atomically claim the export queue and return a set of unique image IDs.
local function drain_export_queue()
local processing = EXPORT_QUEUE .. ".processing"
local ok = os.rename(EXPORT_QUEUE, processing)
if not ok then return {} end
local ids = {}
local f = io.open(processing, "r")
if f then
for line in f:lines() do
local id = tonumber(line)
if id then ids[id] = true end
end
f:close()
os.remove(processing)
end
return ids
end
-- Process all pending refresh IDs in one pass.
local function process_queue()
local ids = drain_queue()
local count = 0
for id, _ in pairs(ids) do count = count + 1 end
if count == 0 then return end
log(string.format("processing %d queued refresh request(s)", count))
local id_map = build_id_map()
for id, _ in pairs(ids) do
local img = id_map[id]
if img then
local ok, err = pcall(refresh_image, img)
if not ok then
log("error refreshing id=" .. id .. ": " .. tostring(err))
end
else
log("image id=" .. id .. " not found in database (may have been removed)")
end
end
-- Nudge the lighttable to redraw so updated thumbnails appear immediately.
dt.gui.views.lighttable.is_culling_mode = dt.gui.views.lighttable.is_culling_mode
end
-- Process all pending export IDs in one pass.
local function process_export_queue()
local ids = drain_export_queue()
local count = 0
for id, _ in pairs(ids) do count = count + 1 end
if count == 0 then return end
-- Ensure export directory exists.
os.execute('mkdir -p "' .. EXPORT_DIR .. '"')
log(string.format("processing %d queued export request(s)", count))
local id_map = build_id_map()
for id, _ in pairs(ids) do
local img = id_map[id]
if img then
local dest = EXPORT_DIR .. "/" .. tostring(id) .. ".jpg"
local ok, err = pcall(export_image, img, dest)
if not ok then
log("error exporting id=" .. id .. ": " .. tostring(err))
end
else
log("export: image id=" .. id .. " not found in database")
end
end
end
-- ── Background polling loop ───────────────────────────────────────────────────
-- dt.control.dispatch runs the function asynchronously in darktable's worker
-- thread pool, leaving the UI thread free. We loop inside with sleep() so
-- one dispatch call drives the whole bridge.
local running = true
local function poll_loop()
notify(string.format(
"started; polling queues every %d ms\n refresh: %s\n export: %s → %s",
POLL_MS, QUEUE_FILE, EXPORT_QUEUE, EXPORT_DIR))
while running do
local ok, err = pcall(process_queue)
if not ok then log("process_queue error: " .. tostring(err)) end
local ok2, err2 = pcall(process_export_queue)
if not ok2 then log("process_export_queue error: " .. tostring(err2)) end
dt.control.sleep(POLL_MS)
end
notify("stopped")
end
-- ── Plugin lifecycle ──────────────────────────────────────────────────────────
local script_data = {}
script_data.metadata = {
name = "darktable API bridge",
purpose = "Watch the REST API refresh queue and regenerate image thumbnails",
author = "darktable-api",
help = "https://github.com/darktable-org/lua-scripts",
}
local function destroy()
running = false
-- Give the poll loop one final chance to exit cleanly.
dt.control.sleep(POLL_MS + 200)
dt.destroy_event(MODULE, "shortcut")
end
script_data.destroy = destroy
-- Register a manual-trigger shortcut so the user can force an immediate poll
-- without waiting for the next timer tick.
dt.register_event(MODULE, "shortcut",
function(event, shortcut)
log("manual poll triggered via shortcut")
local ok, err = pcall(process_queue)
if not ok then notify("error during manual poll: " .. tostring(err)) end
local ok2, err2 = pcall(process_export_queue)
if not ok2 then notify("export error during manual poll: " .. tostring(err2)) end
end,
"poll refresh queue now"
)
-- Start the background loop.
dt.control.dispatch(poll_loop)
return script_data

View File

@@ -0,0 +1,525 @@
-- Darktable SQLite catalog access (read-only for image queries; read-write for
-- module-parameter edits via the history table).
-- Schema inspected from an actual darktable library (recent version):
--
-- images(id, group_id, film_id, filename, width, height, aspect_ratio,
-- maker_id, model_id, lens_id, camera_id,
-- exposure, aperture, iso, focal_length, focus_distance,
-- datetime_taken INTEGER (Unix epoch),
-- flags, orientation, longitude, latitude, altitude,
-- version, max_version, write_timestamp, change_timestamp, ...)
-- makers(id, name) models(id, name) lens(id, name)
-- film_rolls(id, access_timestamp, folder)
-- tagged_images(imgid, tagid, position)
-- color_labels(imgid, color) -- color: 0=red 1=yellow 2=green 3=blue 4=purple
-- data.tags(id, name, ...) -- in data.db
-- meta_data(id, key, value) -- key references data.meta_data.key
--
-- images.flags & 0x07 → rating (0=unrated, 1-5=stars, 6=rejected)
-- images.flags & 64 → DT_IMAGE_RAW
-- images.flags & 128 → DT_IMAGE_HDR
-- images.flags & 32 → DT_IMAGE_LDR
local sqlite3 = require "lsqlite3"
local bit = require "bit" -- LuaJIT built-in; Lua 5.1 bitwise
local config = require "config"
local band = bit.band
local unpack = unpack or table.unpack -- Lua 5.1 vs 5.2+ compatibility
local M = {}
-- ── Connections ───────────────────────────────────────────────────────────────
local _db -- read-only, lazy
local _db_rw -- read-write, opened on first write
local function get_db()
if _db then return _db end
_db = sqlite3.open(config.library_db, sqlite3.OPEN_READONLY)
if not _db then
error("Cannot open darktable library: " .. config.library_db)
end
local data_db = config.data_db:gsub("'", "''")
_db:exec(string.format("ATTACH DATABASE '%s' AS data", data_db))
return _db
end
local function get_db_rw()
if _db_rw then return _db_rw end
_db_rw = sqlite3.open(config.library_db) -- read-write
if not _db_rw then
error("Cannot open darktable library for writing: " .. config.library_db)
end
-- WAL mode: allow concurrent reads from the darktable process.
_db_rw:exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;")
return _db_rw
end
-- ── Query helpers ─────────────────────────────────────────────────────────────
local function query(sql, params)
local d = get_db()
local stmt = d:prepare(sql)
if not stmt then
error("SQL error: " .. (d:errmsg() or "?") .. "\nSQL: " .. sql)
end
if params and #params > 0 then
stmt:bind_values(unpack(params))
end
local rows = {}
for row in stmt:nrows() do
rows[#rows+1] = row
end
stmt:finalize()
return rows
end
local function query_one(sql, params)
return query(sql, params)[1]
end
-- ── Schema helpers ────────────────────────────────────────────────────────────
-- Rating is packed into the low 3 bits of flags: 0=unrated, 1-5=stars, 6=rejected
local function decode_rating(flags)
local r = band(flags or 0, 0x07)
return r == 6 and -1 or r
end
-- datetime_taken is stored as GLib microseconds since 0001-01-01 00:00:00 UTC.
-- Unix epoch (1970-01-01) = 62135596800 seconds after 0001-01-01.
local GLIB_EPOCH_OFFSET = 62135596800 -- seconds
local function glib_to_unix(glib_us)
if not glib_us or glib_us == 0 then return nil end
return math.floor(glib_us / 1000000) - GLIB_EPOCH_OFFSET
end
local function fmt_datetime(glib_us)
local unix_ts = glib_to_unix(glib_us)
if not unix_ts then return nil end
return os.date("!%Y-%m-%dT%H:%M:%SZ", unix_ts)
end
local LABEL_NAMES = { [0]="red", [1]="yellow", [2]="green", [3]="blue", [4]="purple" }
local function row_to_image(row)
if not row then return nil end
return {
id = row.id,
film_id = row.film_id,
group_id = row.group_id,
filename = row.filename,
path = row.folder,
width = row.width,
height = row.height,
aspect_ratio = row.aspect_ratio,
maker = row.maker,
model = row.model,
lens = row.lens,
exposure = row.exposure,
aperture = row.aperture,
iso = row.iso,
focal_length = row.focal_length,
datetime_taken = fmt_datetime(row.datetime_taken),
rating = decode_rating(row.flags),
is_raw = band(row.flags or 0, 64) ~= 0,
is_hdr = band(row.flags or 0, 128) ~= 0,
is_ldr = band(row.flags or 0, 32) ~= 0,
latitude = row.latitude,
longitude = row.longitude,
altitude = row.altitude,
version = row.version,
write_timestamp = row.write_timestamp,
change_timestamp = row.change_timestamp,
}
end
-- Standard SELECT columns + JOINs used by both list_images and get_image.
local IMAGE_SELECT = [[
SELECT i.id, i.group_id, i.film_id, i.filename,
i.width, i.height, i.aspect_ratio,
mk.name AS maker, mo.name AS model, ln.name AS lens,
i.exposure, i.aperture, i.iso, i.focal_length,
i.datetime_taken, i.flags, i.version,
i.longitude, i.latitude, i.altitude,
i.write_timestamp, i.change_timestamp,
f.folder
FROM images i
JOIN film_rolls f ON f.id = i.film_id
LEFT JOIN makers mk ON mk.id = i.maker_id
LEFT JOIN models mo ON mo.id = i.model_id
LEFT JOIN lens ln ON ln.id = i.lens_id
]]
-- ── Filter builder ────────────────────────────────────────────────────────────
local COLOR_MAP = { red=0, yellow=1, green=2, blue=3, purple=4 }
local VALID_SORT = {
id=true, filename=true, datetime_taken=true, rating=true,
focal_length=true, iso=true,
}
-- Convert a YYYY-MM-DD string to a GLib microsecond timestamp for DB comparison.
local function date_to_glib_us(date_str, end_of_day)
local y, m, d = date_str:match("(%d%d%d%d)[%-:](%d%d)[%-:](%d%d)")
if not y then return nil end
local unix_ts = os.time({
year=tonumber(y), month=tonumber(m), day=tonumber(d),
hour = end_of_day and 23 or 0,
min = end_of_day and 59 or 0,
sec = end_of_day and 59 or 0,
})
return (unix_ts + GLIB_EPOCH_OFFSET) * 1000000
end
local function build_filter(filters)
local conds = {}
local params = {}
-- Star rating (packed in flags & 0x07; 6 = rejected)
if filters.rating_min then
local r = tonumber(filters.rating_min) or 0
table.insert(conds, "(i.flags & 7) >= ?")
table.insert(params, r == -1 and 6 or r)
end
if filters.rating_max then
local r = tonumber(filters.rating_max) or 5
table.insert(conds, "(i.flags & 7) <= ?")
table.insert(params, r == -1 and 6 or r)
end
if filters.rating then
local r = tonumber(filters.rating)
if r then
table.insert(conds, "(i.flags & 7) = ?")
table.insert(params, r == -1 and 6 or r)
end
end
-- Film roll / folder
if filters.film_id then
table.insert(conds, "i.film_id = ?")
table.insert(params, tonumber(filters.film_id))
end
-- Date range (datetime_taken is GLib microseconds since 0001-01-01)
if filters.from then
local glib_us = date_to_glib_us(filters.from, false)
if glib_us then
table.insert(conds, "i.datetime_taken >= ?")
table.insert(params, glib_us)
end
end
if filters.to then
local glib_us = date_to_glib_us(filters.to, true)
if glib_us then
table.insert(conds, "i.datetime_taken <= ?")
table.insert(params, glib_us)
end
end
-- Color label
if filters.color_label then
local cid = COLOR_MAP[filters.color_label:lower()]
if cid ~= nil then
table.insert(conds, "EXISTS (SELECT 1 FROM color_labels cl WHERE cl.imgid=i.id AND cl.color=?)")
table.insert(params, cid)
end
end
-- RAW / HDR flags (SQLite supports & bitwise)
if filters.is_raw == "true" then table.insert(conds, "(i.flags & 64) != 0") end
if filters.is_raw == "false" then table.insert(conds, "(i.flags & 64) = 0") end
if filters.is_hdr == "true" then table.insert(conds, "(i.flags & 128) != 0") end
-- Free-text search across maker/model/lens/filename
if filters.search and filters.search ~= "" then
table.insert(conds, "(i.filename LIKE ? OR mk.name LIKE ? OR mo.name LIKE ? OR ln.name LIKE ?)")
local pat = "%" .. filters.search .. "%"
for _ = 1, 4 do table.insert(params, pat) end
end
-- Tag filter (correlated EXISTS; works for both data.tags and main.tags layouts)
if filters.tag and filters.tag ~= "" then
table.insert(conds, [[EXISTS (
SELECT 1 FROM tagged_images ti2, data.tags t2
WHERE ti2.imgid = i.id AND t2.id = ti2.tagid AND t2.name = ?)]])
table.insert(params, filters.tag)
end
local where = #conds > 0 and ("WHERE " .. table.concat(conds, " AND ")) or ""
return where, params
end
-- ── Public API ────────────────────────────────────────────────────────────────
function M.list_images(filters)
filters = filters or {}
local where, params = build_filter(filters)
local limit = math.max(1, math.min(tonumber(filters.limit) or 50, 500))
local offset = math.max(0, tonumber(filters.offset) or 0)
local sort_by = VALID_SORT[filters.sort_by] and ("i." .. filters.sort_by) or "i.datetime_taken"
local sort_dir = (filters.sort_dir == "asc") and "ASC" or "DESC"
-- Total count (same filter, no pagination)
local count_params = {}
for _, v in ipairs(params) do count_params[#count_params+1] = v end
local count_sql = string.format(
"SELECT COUNT(*) AS total FROM images i " ..
"JOIN film_rolls f ON f.id = i.film_id " ..
"LEFT JOIN makers mk ON mk.id = i.maker_id " ..
"LEFT JOIN models mo ON mo.id = i.model_id " ..
"LEFT JOIN lens ln ON ln.id = i.lens_id %s",
where)
local count_row = query_one(count_sql, count_params)
local total = count_row and count_row.total or 0
-- Data rows
local data_params = {}
for _, v in ipairs(params) do data_params[#data_params+1] = v end
data_params[#data_params+1] = limit
data_params[#data_params+1] = offset
local data_sql = IMAGE_SELECT ..
where ..
string.format(" ORDER BY %s %s LIMIT ? OFFSET ?", sort_by, sort_dir)
local images = {}
for _, row in ipairs(query(data_sql, data_params)) do
images[#images+1] = row_to_image(row)
end
return { total = total, limit = limit, offset = offset, items = images }
end
function M.get_image(id)
local row = query_one(IMAGE_SELECT .. " WHERE i.id = ?", { tonumber(id) })
if not row then return nil end
local img = row_to_image(row)
-- Tags
local tag_rows = query([[
SELECT t.name FROM tagged_images ti
JOIN data.tags t ON t.id = ti.tagid
WHERE ti.imgid = ? ORDER BY t.name
]], { img.id })
img.tags = {}
for _, tr in ipairs(tag_rows) do img.tags[#img.tags+1] = tr.name end
-- Color labels
local cl_rows = query("SELECT color FROM color_labels WHERE imgid=?", { img.id })
img.color_labels = {}
for _, cl in ipairs(cl_rows) do
if LABEL_NAMES[cl.color] then
img.color_labels[#img.color_labels+1] = LABEL_NAMES[cl.color]
end
end
-- Metadata (caption, description, creator, etc. via data.meta_data key map)
local ok_meta, meta_rows = pcall(query, [[
SELECT dm.tagname, m.value
FROM meta_data m JOIN data.meta_data dm ON dm.key = m.key
WHERE m.id = ?
]], { img.id })
img.metadata = {}
if ok_meta then
for _, mr in ipairs(meta_rows) do
local subkey = mr.tagname:match("%.([^%.]+)$") or mr.tagname
img.metadata[subkey] = mr.value
end
end
return img
end
-- ── Tags ──────────────────────────────────────────────────────────────────────
function M.list_tags(filters)
filters = filters or {}
local conds, params = {}, {}
if filters.search and filters.search ~= "" then
table.insert(conds, "t.name LIKE ?")
table.insert(params, "%" .. filters.search .. "%")
end
local where = #conds > 0 and ("WHERE " .. table.concat(conds, " AND ")) or ""
local limit = math.min(tonumber(filters.limit) or 200, 2000)
local offset = tonumber(filters.offset) or 0
params[#params+1] = limit
params[#params+1] = offset
local rows = query(string.format([[
SELECT t.id, t.name,
(SELECT COUNT(*) FROM tagged_images ti WHERE ti.tagid=t.id) AS image_count
FROM data.tags t %s ORDER BY t.name LIMIT ? OFFSET ?
]], where), params)
local tags = {}
for _, r in ipairs(rows) do
tags[#tags+1] = { id=r.id, name=r.name, image_count=r.image_count }
end
return tags
end
-- ── Film rolls ────────────────────────────────────────────────────────────────
function M.list_films(filters)
filters = filters or {}
local conds, params = {}, {}
if filters.search and filters.search ~= "" then
table.insert(conds, "f.folder LIKE ?")
table.insert(params, "%" .. filters.search .. "%")
end
local where = #conds > 0 and ("WHERE " .. table.concat(conds, " AND ")) or ""
local limit = math.min(tonumber(filters.limit) or 200, 2000)
local offset = tonumber(filters.offset) or 0
params[#params+1] = limit
params[#params+1] = offset
local rows = query(string.format([[
SELECT f.id, f.folder, f.access_timestamp,
(SELECT COUNT(*) FROM images i WHERE i.film_id=f.id) AS image_count
FROM film_rolls f %s ORDER BY f.folder LIMIT ? OFFSET ?
]], where), params)
local films = {}
for _, r in ipairs(rows) do
films[#films+1] = {
id = r.id,
folder = r.folder,
access_timestamp = r.access_timestamp,
image_count = r.image_count,
}
end
return films
end
-- ── Module history ────────────────────────────────────────────────────────────
-- The history table stores one or more entries per (imgid, operation).
-- Only the entry with the highest `num` is the active one.
-- `module` column = the params-struct version number for that row.
local modules_mod -- lazy-loaded to avoid circular requires
local function get_modules_mod()
if not modules_mod then modules_mod = require "modules" end
return modules_mod
end
local function history_row_to_module(row)
local mods = get_modules_mod()
local blob = row.op_params or ""
local ver = row.module or 0
local params = mods.decode(row.operation, ver, blob)
return {
operation = row.operation,
enabled = row.enabled ~= 0,
module_version = ver,
multi_name = row.multi_name or "",
multi_priority = row.multi_priority or 0,
history_num = row.num,
params = params, -- nil for unknown modules
params_b64 = params and nil or require("base64").encode(blob),
}
end
-- Return the active (highest-num) history entry for every operation on imgid.
function M.list_modules(imgid)
local rows = query([[
SELECT h.operation, h.module, h.enabled, h.op_params,
h.multi_name, h.multi_priority, h.num
FROM history h
INNER JOIN (
SELECT operation, MAX(num) AS max_num
FROM history WHERE imgid = ?
GROUP BY operation
) latest ON h.operation = latest.operation AND h.num = latest.max_num
WHERE h.imgid = ?
ORDER BY h.operation
]], { tonumber(imgid), tonumber(imgid) })
local result = {}
for _, row in ipairs(rows) do
result[#result+1] = history_row_to_module(row)
end
return result
end
-- Return the raw op_params binary blob for one operation (latest history entry).
function M.get_module_blob(imgid, operation)
local row = query_one([[
SELECT op_params FROM history
WHERE imgid = ? AND operation = ?
ORDER BY num DESC LIMIT 1
]], { tonumber(imgid), operation })
return row and row.op_params or nil
end
-- Return the single active history entry for one operation.
function M.get_module(imgid, operation)
local row = query_one([[
SELECT h.operation, h.module, h.enabled, h.op_params,
h.multi_name, h.multi_priority, h.num
FROM history h
WHERE h.imgid = ? AND h.operation = ?
ORDER BY h.num DESC LIMIT 1
]], { tonumber(imgid), operation })
if not row then return nil end
return history_row_to_module(row)
end
-- Update the active history entry for an operation.
-- `new_blob` (string|nil) – new binary op_params; nil to leave unchanged
-- `new_enabled` (bool|nil) – new enabled flag; nil to leave unchanged
-- Returns true on success, or false + error message.
function M.set_module_params(imgid, operation, new_enabled, new_blob)
-- Find the current latest entry and its num.
local row = query_one([[
SELECT num, module, op_params, enabled, blendop_params, blendop_version,
multi_priority, multi_name, multi_name_hand_edited
FROM history
WHERE imgid = ? AND operation = ?
ORDER BY num DESC LIMIT 1
]], { tonumber(imgid), operation })
if not row then
return false, "module '" .. operation .. "' not found in history for image " .. imgid
end
local d = get_db_rw()
-- Find the global max num for this image so we can insert after it.
local max_row = query_one("SELECT MAX(num) AS m FROM history WHERE imgid=?", {tonumber(imgid)})
local new_num = (max_row and max_row.m or 0) + 1
local enabled_val = (new_enabled == nil) and row.enabled or (new_enabled and 1 or 0)
local blob_val = new_blob or row.op_params
-- Insert a new history entry (preserves darktable's undo history).
local sql = [[
INSERT INTO history
(imgid, num, module, operation, op_params, enabled,
blendop_params, blendop_version, multi_priority, multi_name, multi_name_hand_edited)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
]]
local stmt = d:prepare(sql)
if not stmt then
return false, "SQL prepare failed: " .. (d:errmsg() or "?")
end
stmt:bind_values(
tonumber(imgid), new_num, row.module, operation, blob_val, enabled_val,
row.blendop_params, row.blendop_version,
row.multi_priority, row.multi_name, row.multi_name_hand_edited
)
local rc = stmt:step()
stmt:finalize()
if rc ~= sqlite3.DONE then
return false, "INSERT failed (rc=" .. tostring(rc) .. "): " .. (d:errmsg() or "?")
end
return true
end
return M

View File

@@ -0,0 +1,155 @@
-- Minimal JSON encoder/decoder (pure Lua, no dependencies)
local M = {}
-- ── Encoder ──────────────────────────────────────────────────────────────────
-- Iterate byte-by-byte to avoid null-byte issues in Lua pattern ranges.
local function encode_str(s)
local buf = {'"'}
for i = 1, #s do
local b = s:byte(i)
if b == 34 then buf[#buf+1] = '\\"'
elseif b == 92 then buf[#buf+1] = '\\\\'
elseif b == 10 then buf[#buf+1] = '\\n'
elseif b == 13 then buf[#buf+1] = '\\r'
elseif b == 9 then buf[#buf+1] = '\\t'
elseif b < 32 or b == 127 then
buf[#buf+1] = string.format('\\u%04x', b)
else
buf[#buf+1] = s:sub(i, i)
end
end
buf[#buf+1] = '"'
return table.concat(buf)
end
local function enc(v, depth)
if depth > 64 then error("json.encode: table too deeply nested") end
local t = type(v)
if t == "nil" then return "null"
elseif t == "boolean" then return tostring(v)
elseif t == "number" then
if v ~= v or math.abs(v) == math.huge then return "null" end
return v == math.floor(v) and string.format("%d", v) or string.format("%.10g", v)
elseif t == "string" then
return encode_str(v)
elseif t == "table" then
-- Treat as array when it has a consecutive integer key sequence from 1
if #v > 0 then
local parts = {}
for i = 1, #v do parts[i] = enc(v[i], depth + 1) end
return "[" .. table.concat(parts, ",") .. "]"
end
local parts = {}
for k, x in pairs(v) do
if type(k) == "string" then
parts[#parts+1] = encode_str(k) .. ":" .. enc(x, depth + 1)
end
end
return "{" .. table.concat(parts, ",") .. "}"
end
return "null"
end
function M.encode(v) return enc(v, 0) end
-- ── Decoder ───────────────────────────────────────────────────────────────────
function M.decode(src)
local pos = 1
local function skip()
while pos <= #src and src:byte(pos) <= 32 do pos = pos + 1 end
end
local parse -- forward declaration
local function parse_string()
pos = pos + 1 -- consume opening "
local buf = {}
while pos <= #src do
local c = src:sub(pos, pos)
if c == '"' then pos = pos + 1; return table.concat(buf) end
if c == '\\' then
pos = pos + 1
c = src:sub(pos, pos)
if c == '"' then buf[#buf+1] = '"'
elseif c == '\\' then buf[#buf+1] = '\\'
elseif c == '/' then buf[#buf+1] = '/'
elseif c == 'n' then buf[#buf+1] = '\n'
elseif c == 'r' then buf[#buf+1] = '\r'
elseif c == 't' then buf[#buf+1] = '\t'
elseif c == 'b' then buf[#buf+1] = '\b'
elseif c == 'f' then buf[#buf+1] = '\f'
elseif c == 'u' then
local hex = tonumber(src:sub(pos+1, pos+4), 16) or 0
-- Only emit ASCII; multi-byte unicode emits a placeholder
buf[#buf+1] = hex < 128 and string.char(hex) or '\xEF\xBF\xBD'
pos = pos + 4
end
else
buf[#buf+1] = c
end
pos = pos + 1
end
error("json.decode: unterminated string near pos " .. pos)
end
local function parse_object()
pos = pos + 1 -- consume '{'
local obj = {}
skip()
if src:sub(pos, pos) == '}' then pos = pos + 1; return obj end
while true do
skip()
local key = parse_string()
skip()
if src:sub(pos, pos) ~= ':' then error("json.decode: expected ':' near pos " .. pos) end
pos = pos + 1
skip()
obj[key] = parse()
skip()
local ch = src:sub(pos, pos); pos = pos + 1
if ch == '}' then return obj end
if ch ~= ',' then error("json.decode: expected ',' or '}' near pos " .. pos) end
end
end
local function parse_array()
pos = pos + 1 -- consume '['
local arr = {}
skip()
if src:sub(pos, pos) == ']' then pos = pos + 1; return arr end
while true do
skip()
arr[#arr+1] = parse()
skip()
local ch = src:sub(pos, pos); pos = pos + 1
if ch == ']' then return arr end
if ch ~= ',' then error("json.decode: expected ',' or ']' near pos " .. pos) end
end
end
parse = function()
skip()
local c = src:sub(pos, pos)
if c == '"' then return parse_string()
elseif c == '{' then return parse_object()
elseif c == '[' then return parse_array()
elseif c == 't' then pos = pos + 4; return true
elseif c == 'f' then pos = pos + 5; return false
elseif c == 'n' then pos = pos + 4; return nil
else
-- Parentheses create the capture; without them find returns only start/end.
local _, e, n = src:find("^(-?%d+%.?%d*[eE]?[+-]?%d*)", pos)
if n then pos = e + 1; return tonumber(n) end
error("json.decode: unexpected token '" .. c .. "' at pos " .. pos)
end
end
local ok, result = pcall(parse)
if not ok then return nil, result end
return result
end
return M

View File

@@ -0,0 +1,502 @@
-- Module parameter decoder / encoder for the darktable REST API.
-- Runs in the LuaJIT server process. Uses FFI to unpack and repack the
-- binary op_params blobs stored in the library.db history table.
--
-- Each struct definition mirrors the darktable 4.6 iop C struct exactly so
-- that sizeof() matches the byte count in the database. The `module` column
-- in the history table carries the version number stored for that row.
--
-- Unknown modules (or unsupported versions) pass through as a base64 string.
local ffi = require "ffi"
local base64 = require "base64"
-- ── C struct definitions ───────────────────────────────────────────────────────
ffi.cdef[[
/* --- flip (ver=2, 4 bytes) --- */
typedef struct { int32_t orientation; } dt_flip_v2_t;
/* --- gamma / display encoding (ver=1, 8 bytes) --- */
typedef struct { float gamma; float linear; } dt_gamma_v1_t;
/* --- temperature / white balance (ver=3, 16 bytes) --- */
typedef struct { float red; float green; float blue; float various; } dt_temperature_v3_t;
/* --- exposure (ver=7, 28 bytes) --- */
typedef struct {
int32_t mode;
float black;
float exposure;
float deflicker_percentile;
float deflicker_target_level;
int32_t compensate_exposure_bias;
int32_t compensate_hilite_pres;
} dt_exposure_v7_t;
/* --- rawprepare (ver=1, 28 bytes) --- */
typedef struct {
int32_t left; int32_t top; int32_t right; int32_t bottom;
uint16_t raw_black_level_separate[4];
uint16_t raw_white_point;
uint16_t _pad1;
} dt_rawprepare_v1_t;
/* --- rawprepare (ver=2, 32 bytes) --- */
typedef struct {
int32_t left; int32_t top; int32_t right; int32_t bottom;
uint16_t raw_black_level_separate[4];
uint16_t raw_white_point;
uint16_t _pad1;
int32_t flat_field;
} dt_rawprepare_v2_t;
/* --- demosaic (ver=3, 20 bytes) --- */
typedef struct {
int32_t green_eq;
float median_thrs;
int32_t color_smoothing;
int32_t demosaicing_method;
int32_t lmmse_refine;
} dt_demosaic_v3_t;
/* --- highlights (ver=4, 48 bytes) --- */
typedef struct {
int32_t mode;
float blendL;
float blendC;
float strength;
float clip;
float noise_level;
int32_t iterations;
int32_t scales;
float candidating;
float combine;
int32_t recovery;
float solid_color;
} dt_highlights_v4_t;
/* --- channelmixerrgb (ver=3, 160 bytes) --- */
typedef struct {
float red[4];
float green[4];
float blue[4];
float saturation[4];
float lightness[4];
float grey[4];
int32_t normalize_R;
int32_t normalize_G;
int32_t normalize_B;
int32_t normalize_sat;
int32_t normalize_light;
int32_t normalize_grey;
int32_t illuminant;
int32_t illum_fluo;
int32_t illum_led;
int32_t adaptation;
float x;
float y;
float temperature;
float gamut;
int32_t clip;
int32_t version;
} dt_channelmixerrgb_v3_t;
/* --- filmicrgb (ver=6, 116 bytes) --- */
typedef struct {
float grey_point_source;
float black_point_source;
float white_point_source;
float reconstruct_threshold;
float reconstruct_feather;
float reconstruct_bloom_vs_details;
float reconstruct_grey_vs_color;
float reconstruct_structure_vs_texture;
float security_factor;
float grey_point_target;
float black_point_target;
float white_point_target;
float output_power;
float latitude;
float contrast;
float saturation;
float balance;
float noise_level;
int32_t preserve_color;
int32_t version;
int32_t auto_hardness;
int32_t custom_grey;
int32_t high_quality_reconstruction;
int32_t noise_distribution;
int32_t shadows;
int32_t highlights;
int32_t compensate_icc_black;
int32_t spline_version;
int32_t enable_highlight_reconstruction;
} dt_filmicrgb_v6_t;
]]
-- ── Internal helpers ──────────────────────────────────────────────────────────
local function bool(v) return v ~= 0 end
-- Cast a Lua binary string into a pointer to ctype.
local function cast_blob(ctype, blob)
local buf = ffi.new("uint8_t[?]", #blob)
ffi.copy(buf, blob, #blob)
return ffi.cast(ctype, buf), buf -- return buf too so GC doesn't collect it
end
-- Read a 4-float array field from a cdata array into a Lua table.
local function farr(p, n)
n = n or 4
local t = {}
for i = 0, n-1 do t[i+1] = p[i] end
return t
end
-- Write a Lua table back into a cdata 4-float array.
local function set_farr(p, t, n)
n = n or 4
for i = 0, n-1 do
if t[i+1] ~= nil then p[i] = t[i+1] end
end
end
-- Apply a field-level patch: if `src[k]` exists, set `dst.k = src[k]`.
local function patch_fields(dst, src, ...)
for _, k in ipairs({...}) do
if src[k] ~= nil then dst[k] = src[k] end
end
end
-- Build a fresh binary blob by copying the existing one and applying changes.
local function make_blob(ctype, size, existing, apply_fn)
local buf = ffi.new("uint8_t[?]", size)
if existing then ffi.copy(buf, existing, size) end
local p = ffi.cast(ctype, buf)
apply_fn(p)
return ffi.string(buf, size)
end
-- ── Per-module decode / encode ────────────────────────────────────────────────
local function decode_flip(blob, ver)
if ver ~= 2 or #blob ~= 4 then return nil end
local p, _ = cast_blob("dt_flip_v2_t *", blob)
return { orientation = tonumber(p.orientation) }
end
local function encode_flip(blob, changes)
return make_blob("dt_flip_v2_t *", 4, blob, function(p)
if changes.orientation ~= nil then p.orientation = changes.orientation end
end)
end
-- ---------------------------------------------------------------------------
local function decode_gamma(blob, ver)
if ver ~= 1 or #blob ~= 8 then return nil end
local p, _ = cast_blob("dt_gamma_v1_t *", blob)
return { gamma = tonumber(p.gamma), linear = tonumber(p.linear) }
end
local function encode_gamma(blob, changes)
return make_blob("dt_gamma_v1_t *", 8, blob, function(p)
patch_fields(p, changes, "gamma", "linear")
end)
end
-- ---------------------------------------------------------------------------
local function decode_temperature(blob, ver)
if ver ~= 3 or #blob ~= 16 then return nil end
local p, _ = cast_blob("dt_temperature_v3_t *", blob)
return {
red = tonumber(p.red),
green = tonumber(p.green),
blue = tonumber(p.blue),
various = tonumber(p.various),
}
end
local function encode_temperature(blob, changes)
return make_blob("dt_temperature_v3_t *", 16, blob, function(p)
patch_fields(p, changes, "red", "green", "blue", "various")
end)
end
-- ---------------------------------------------------------------------------
local function decode_exposure(blob, ver)
if ver ~= 7 or #blob ~= 28 then return nil end
local p, _ = cast_blob("dt_exposure_v7_t *", blob)
return {
mode = tonumber(p.mode),
black = tonumber(p.black),
exposure = tonumber(p.exposure),
deflicker_percentile = tonumber(p.deflicker_percentile),
deflicker_target_level = tonumber(p.deflicker_target_level),
compensate_exposure_bias = bool(p.compensate_exposure_bias),
compensate_hilite_pres = bool(p.compensate_hilite_pres),
}
end
local function encode_exposure(blob, changes)
return make_blob("dt_exposure_v7_t *", 28, blob, function(p)
patch_fields(p, changes, "mode", "black", "exposure",
"deflicker_percentile", "deflicker_target_level")
if changes.compensate_exposure_bias ~= nil then
p.compensate_exposure_bias = changes.compensate_exposure_bias and 1 or 0
end
if changes.compensate_hilite_pres ~= nil then
p.compensate_hilite_pres = changes.compensate_hilite_pres and 1 or 0
end
end)
end
-- ---------------------------------------------------------------------------
local function decode_rawprepare(blob, ver)
if ver == 1 and #blob == 28 then
local p, _ = cast_blob("dt_rawprepare_v1_t *", blob)
return {
left = tonumber(p.left), top = tonumber(p.top),
right = tonumber(p.right), bottom = tonumber(p.bottom),
raw_black_level_separate = farr(p.raw_black_level_separate, 4),
raw_white_point = tonumber(p.raw_white_point),
}
elseif ver == 2 and #blob == 32 then
local p, _ = cast_blob("dt_rawprepare_v2_t *", blob)
return {
left = tonumber(p.left), top = tonumber(p.top),
right = tonumber(p.right), bottom = tonumber(p.bottom),
raw_black_level_separate = farr(p.raw_black_level_separate, 4),
raw_white_point = tonumber(p.raw_white_point),
flat_field = tonumber(p.flat_field),
}
end
return nil
end
local function encode_rawprepare(blob, changes, ver)
if ver == 1 then
return make_blob("dt_rawprepare_v1_t *", 28, blob, function(p)
patch_fields(p, changes, "left", "top", "right", "bottom", "raw_white_point")
if changes.raw_black_level_separate then
set_farr(p.raw_black_level_separate, changes.raw_black_level_separate, 4)
end
end)
else
return make_blob("dt_rawprepare_v2_t *", 32, blob, function(p)
patch_fields(p, changes, "left", "top", "right", "bottom", "raw_white_point", "flat_field")
if changes.raw_black_level_separate then
set_farr(p.raw_black_level_separate, changes.raw_black_level_separate, 4)
end
end)
end
end
-- ---------------------------------------------------------------------------
local function decode_demosaic(blob, ver)
if ver ~= 3 or #blob ~= 20 then return nil end
local p, _ = cast_blob("dt_demosaic_v3_t *", blob)
return {
green_eq = tonumber(p.green_eq),
median_thrs = tonumber(p.median_thrs),
color_smoothing = tonumber(p.color_smoothing),
demosaicing_method = tonumber(p.demosaicing_method),
lmmse_refine = tonumber(p.lmmse_refine),
}
end
local function encode_demosaic(blob, changes)
return make_blob("dt_demosaic_v3_t *", 20, blob, function(p)
patch_fields(p, changes, "green_eq", "median_thrs", "color_smoothing",
"demosaicing_method", "lmmse_refine")
end)
end
-- ---------------------------------------------------------------------------
local function decode_highlights(blob, ver)
if ver ~= 4 or #blob ~= 48 then return nil end
local p, _ = cast_blob("dt_highlights_v4_t *", blob)
return {
mode = tonumber(p.mode),
blendL = tonumber(p.blendL),
blendC = tonumber(p.blendC),
strength = tonumber(p.strength),
clip = tonumber(p.clip),
noise_level = tonumber(p.noise_level),
iterations = tonumber(p.iterations),
scales = tonumber(p.scales),
candidating = tonumber(p.candidating),
combine = tonumber(p.combine),
recovery = tonumber(p.recovery),
solid_color = tonumber(p.solid_color),
}
end
local function encode_highlights(blob, changes)
return make_blob("dt_highlights_v4_t *", 48, blob, function(p)
patch_fields(p, changes, "mode", "blendL", "blendC", "strength", "clip",
"noise_level", "iterations", "scales", "candidating",
"combine", "recovery", "solid_color")
end)
end
-- ---------------------------------------------------------------------------
local function decode_channelmixerrgb(blob, ver)
if ver ~= 3 or #blob ~= 160 then return nil end
local p, _ = cast_blob("dt_channelmixerrgb_v3_t *", blob)
return {
red = farr(p.red),
green = farr(p.green),
blue = farr(p.blue),
saturation = farr(p.saturation),
lightness = farr(p.lightness),
grey = farr(p.grey),
normalize_R = bool(p.normalize_R),
normalize_G = bool(p.normalize_G),
normalize_B = bool(p.normalize_B),
normalize_sat = bool(p.normalize_sat),
normalize_light = bool(p.normalize_light),
normalize_grey = bool(p.normalize_grey),
illuminant = tonumber(p.illuminant),
illum_fluo = tonumber(p.illum_fluo),
illum_led = tonumber(p.illum_led),
adaptation = tonumber(p.adaptation),
x = tonumber(p.x),
y = tonumber(p.y),
temperature = tonumber(p.temperature),
gamut = tonumber(p.gamut),
clip = bool(p.clip),
version = tonumber(p.version),
}
end
local function encode_channelmixerrgb(blob, changes)
return make_blob("dt_channelmixerrgb_v3_t *", 160, blob, function(p)
for _, k in ipairs({"red","green","blue","saturation","lightness","grey"}) do
if changes[k] then set_farr(p[k], changes[k], 4) end
end
for _, k in ipairs({"normalize_R","normalize_G","normalize_B",
"normalize_sat","normalize_light","normalize_grey","clip"}) do
if changes[k] ~= nil then p[k] = changes[k] and 1 or 0 end
end
patch_fields(p, changes, "illuminant","illum_fluo","illum_led","adaptation",
"x","y","temperature","gamut","version")
end)
end
-- ---------------------------------------------------------------------------
local function decode_filmicrgb(blob, ver)
if ver ~= 6 or #blob ~= 116 then return nil end
local p, _ = cast_blob("dt_filmicrgb_v6_t *", blob)
return {
grey_point_source = tonumber(p.grey_point_source),
black_point_source = tonumber(p.black_point_source),
white_point_source = tonumber(p.white_point_source),
reconstruct_threshold = tonumber(p.reconstruct_threshold),
reconstruct_feather = tonumber(p.reconstruct_feather),
reconstruct_bloom_vs_details = tonumber(p.reconstruct_bloom_vs_details),
reconstruct_grey_vs_color = tonumber(p.reconstruct_grey_vs_color),
reconstruct_structure_vs_texture = tonumber(p.reconstruct_structure_vs_texture),
security_factor = tonumber(p.security_factor),
grey_point_target = tonumber(p.grey_point_target),
black_point_target = tonumber(p.black_point_target),
white_point_target = tonumber(p.white_point_target),
output_power = tonumber(p.output_power),
latitude = tonumber(p.latitude),
contrast = tonumber(p.contrast),
saturation = tonumber(p.saturation),
balance = tonumber(p.balance),
noise_level = tonumber(p.noise_level),
preserve_color = tonumber(p.preserve_color),
version = tonumber(p.version),
auto_hardness = bool(p.auto_hardness),
custom_grey = bool(p.custom_grey),
high_quality_reconstruction = tonumber(p.high_quality_reconstruction),
noise_distribution = tonumber(p.noise_distribution),
shadows = tonumber(p.shadows),
highlights = tonumber(p.highlights),
compensate_icc_black = bool(p.compensate_icc_black),
spline_version = tonumber(p.spline_version),
enable_highlight_reconstruction = bool(p.enable_highlight_reconstruction),
}
end
local function encode_filmicrgb(blob, changes)
return make_blob("dt_filmicrgb_v6_t *", 116, blob, function(p)
patch_fields(p, changes,
"grey_point_source","black_point_source","white_point_source",
"reconstruct_threshold","reconstruct_feather",
"reconstruct_bloom_vs_details","reconstruct_grey_vs_color",
"reconstruct_structure_vs_texture","security_factor",
"grey_point_target","black_point_target","white_point_target",
"output_power","latitude","contrast","saturation","balance","noise_level",
"preserve_color","version","high_quality_reconstruction",
"noise_distribution","shadows","highlights","spline_version")
for _, k in ipairs({"auto_hardness","custom_grey","compensate_icc_black",
"enable_highlight_reconstruction"}) do
if changes[k] ~= nil then p[k] = changes[k] and 1 or 0 end
end
end)
end
-- ── Dispatch tables ───────────────────────────────────────────────────────────
local DECODERS = {
flip = decode_flip,
gamma = decode_gamma,
temperature = decode_temperature,
exposure = decode_exposure,
rawprepare = decode_rawprepare,
demosaic = decode_demosaic,
highlights = decode_highlights,
channelmixerrgb = decode_channelmixerrgb,
filmicrgb = decode_filmicrgb,
}
local ENCODERS = {
flip = encode_flip,
gamma = encode_gamma,
temperature = encode_temperature,
exposure = encode_exposure,
rawprepare = function(b, c, v) return encode_rawprepare(b, c, v) end,
demosaic = encode_demosaic,
highlights = encode_highlights,
channelmixerrgb = encode_channelmixerrgb,
filmicrgb = encode_filmicrgb,
}
-- ── Public API ────────────────────────────────────────────────────────────────
local M = {}
-- Decode a binary op_params blob for `operation` (ver = history.module column).
-- Returns a Lua table of named fields, or nil if the operation/version is unknown.
function M.decode(operation, ver, blob)
local fn = DECODERS[operation]
if not fn then return nil end
local ok, result = pcall(fn, blob, ver)
if ok then return result end
return nil
end
-- Apply `changes` (a table of field overrides) to `blob` and return the
-- modified binary string. Returns nil if the operation is not known.
-- For unknown modules, the caller should pass params_b64 directly.
function M.encode(operation, ver, blob, changes)
local fn = ENCODERS[operation]
if not fn then return nil end
local ok, result = pcall(fn, blob, changes, ver)
if ok then return result end
return nil
end
return M

View File

@@ -0,0 +1,131 @@
-- Serve image previews from darktable's mipmap cache.
--
-- Darktable stores mipmaps as individual JPEG files at:
-- <cache_dir>/mipmaps-<sha1_of_library_realpath>.d/<mip_level>/<imgid>.jpg
--
-- Mip levels (approximate pixel sizes on the long edge):
-- 0 → ~67 px 1 → ~135 px 2 → ~270 px 3 → ~540 px
-- 4 → ~1080 px (good "medium") 5 → ~2160 px
--
-- If no cached mipmap is found we fall back to exiftool (embedded EXIF
-- thumbnail) and finally to serving the original file path as a redirect.
local ffi = require "ffi"
local config = require "config"
ffi.cdef[[
unsigned char *SHA1(const unsigned char *d, size_t n, unsigned char *md);
]]
local crypto = ffi.load("crypto")
local M = {}
-- ── Locate the mipmap cache directory (computed once) ─────────────────────────
local _mipmap_base -- e.g. "/home/user/.cache/darktable/mipmaps-abc123"
local function mipmap_base()
if _mipmap_base then return _mipmap_base end
-- Resolve the real path of library.db (darktable uses realpath before hashing)
local h = io.popen("realpath " .. config.library_db .. " 2>/dev/null")
local realpath = h:read("*l"); h:close()
realpath = realpath or config.library_db
-- SHA1 hex of the realpath (same algorithm as mipmap_cache.c)
local md = ffi.new("unsigned char[20]")
crypto.SHA1(realpath, #realpath, md)
local hex = {}
for i = 0, 19 do hex[i+1] = string.format("%02x", md[i]) end
local sha1hex = table.concat(hex)
_mipmap_base = config.cache_dir .. "/mipmaps-" .. sha1hex
return _mipmap_base
end
-- ── Size → mip level mapping ──────────────────────────────────────────────────
-- size param: "thumb" (~135 px), "small" (~270 px), "medium" (~540 px),
-- "large" (~1080 px), "xlarge" (~2160 px), or an integer level 0-5.
local SIZE_TO_MIP = {
thumb = 1,
small = 2,
medium = 3,
large = 4,
xlarge = 5,
}
local function size_to_mip(size)
if not size or size == "" then return 3 end -- default: "medium"
local n = tonumber(size)
if n then return math.max(0, math.min(5, math.floor(n))) end
return SIZE_TO_MIP[size:lower()] or 3
end
-- ── Try to read a file, return nil on failure ─────────────────────────────────
local function read_file(path)
local f = io.open(path, "rb")
if not f then return nil end
local data = f:read("*a")
f:close()
return data
end
-- ── Public: get preview data for an image id ──────────────────────────────────
--
-- Returns: data (string), content_type (string)
-- or: nil, nil, redirect_url (caller should issue a 302)
-- or: nil, nil, nil (404)
function M.get(image_id, image_path, filename, size_param)
local mip = size_to_mip(size_param)
-- 1. Walk mip levels: first downward (smaller = smaller file, faster),
-- then upward (larger mip levels, let the browser scale down).
-- This ensures we serve something even when only higher-resolution
-- mipmaps have been cached (e.g. darktable only wrote levels 2-4).
local base = mipmap_base()
for level = mip, 0, -1 do
local path = string.format("%s.d/%d/%d.jpg", base, level, image_id)
local data = read_file(path)
if data then return data, "image/jpeg" end
end
for level = mip + 1, 5 do
local path = string.format("%s.d/%d/%d.jpg", base, level, image_id)
local data = read_file(path)
if data then return data, "image/jpeg" end
end
-- 2. Try exiftool to extract the embedded EXIF thumbnail from the source file.
if image_path and filename then
local src = image_path .. "/" .. filename
-- exiftool writes the thumbnail to stdout with -b
local cmd = string.format("exiftool -b -ThumbnailImage %q 2>/dev/null", src)
local fh = io.popen(cmd)
if fh then
local data = fh:read("*a"); fh:close()
if data and #data > 100 then
return data, "image/jpeg"
end
end
end
-- 3. For JPEG/PNG source files redirect to the original (client caches it).
if image_path and filename then
local src = image_path .. "/" .. filename
local ext = filename:match("%.([^%.]+)$") or ""
if ext:lower():match("^jpe?g$") or ext:lower() == "png" then
return nil, nil, "file://" .. src -- caller issues redirect or reads directly
end
end
return nil, nil, nil -- no preview available
end
-- ── Helper: read a local file and serve it inline ─────────────────────────────
-- Used by the API handler when get() returns a file:// redirect.
function M.read_local(path)
local stripped = path:match("^file://(.+)$") or path
return read_file(stripped)
end
return M

View File

@@ -0,0 +1,68 @@
-- Simple radix-style HTTP router.
-- Supports static segments and named captures (:name).
-- Usage:
-- router.get("/api/v1/images", handler)
-- router.get("/api/v1/images/:id", handler)
-- router.post("/oauth/token", handler)
--
-- local handler, params = router.match("GET", "/api/v1/images/42")
-- -- handler = <function>, params = { id = "42" }
local M = {}
local routes = {} -- { method, pattern_parts, handler }
-- Convert a route pattern like "/api/v1/images/:id/preview" into a segment list.
local function compile(pattern)
local parts = {}
-- Strip leading slash so the segment list aligns with the path splitter below.
for seg in (pattern:gsub("^/", "") .. "/"):gmatch("([^/]*)/") do
if seg:sub(1,1) == ":" then
parts[#parts+1] = { capture = seg:sub(2) }
else
parts[#parts+1] = { literal = seg }
end
end
return parts
end
local function register(method, pattern, handler)
routes[#routes+1] = { method = method:upper(), parts = compile(pattern), handler = handler }
end
function M.get(pattern, handler) register("GET", pattern, handler) end
function M.post(pattern, handler) register("POST", pattern, handler) end
function M.put(pattern, handler) register("PUT", pattern, handler) end
function M.patch(pattern, handler) register("PATCH", pattern, handler) end
function M.delete(pattern, handler) register("DELETE", pattern, handler) end
-- Match a request against registered routes.
-- Returns: handler, path_params or nil, nil
function M.match(method, path)
-- Split path into segments
local segs = {}
for seg in (path:gsub("^/","") .. "/"):gmatch("([^/]*)/") do
segs[#segs+1] = seg
end
method = method:upper()
for _, route in ipairs(routes) do
if route.method == method and #route.parts == #segs then
local params = {}
local matched = true
for i, part in ipairs(route.parts) do
if part.capture then
params[part.capture] = segs[i]
elseif part.literal ~= segs[i] then
matched = false
break
end
end
if matched then return route.handler, params end
end
end
return nil, nil
end
return M

View File

@@ -0,0 +1,280 @@
#!/usr/bin/env luajit
-- Darktable REST API — standalone LuaJIT HTTP server
-- Usage: luajit server.lua [port] [host]
--
-- Dependencies (install via luarocks):
-- luasocket — luarocks install luasocket
-- lsqlite3 — luarocks install lsqlite3
-- libcrypto — provided by the system OpenSSL package
--
-- Quick-start:
-- export DT_CLIENT_ID=myclient DT_CLIENT_SECRET=mysecret DT_JWT_SECRET=longrandomstring
-- luajit server.lua
--
-- Then get a token:
-- curl -s -X POST http://127.0.0.1:7645/oauth/token \
-- -d 'grant_type=client_credentials&client_id=myclient&client_secret=mysecret'
--
-- And use it:
-- curl -s -H "Authorization: Bearer <token>" http://127.0.0.1:7645/api/v1/images
-- ── Bootstrap: make local modules findable ────────────────────────────────────
local script_dir = debug.getinfo(1, "S").source:match("^@(.+)/[^/]+$") or "."
package.path = script_dir .. "/?.lua;" .. package.path
-- ── Load modules ──────────────────────────────────────────────────────────────
local socket = require "socket"
local config = require "config"
local router = require "router"
local api = require "api"
-- ── Static file server (PWA) ──────────────────────────────────────────────────
local WEB_BUILD = script_dir .. "/web/build"
local MIME = {
html = "text/html; charset=utf-8",
js = "application/javascript; charset=utf-8",
mjs = "application/javascript; charset=utf-8",
css = "text/css; charset=utf-8",
json = "application/json",
webmanifest = "application/manifest+json",
png = "image/png",
jpg = "image/jpeg",
jpeg = "image/jpeg",
svg = "image/svg+xml",
ico = "image/x-icon",
woff = "font/woff",
woff2 = "font/woff2",
txt = "text/plain; charset=utf-8",
map = "application/json",
}
-- Read a file from the web build directory. Returns data, content-type or nil.
-- Prevents directory traversal by rejecting any path containing "..".
local function read_static(req_path)
if req_path:find("%.%.", 1, true) then return nil end
-- Strip leading slash; normalise double-slashes.
local rel = req_path:gsub("^/+", ""):gsub("//+", "/")
local full = WEB_BUILD .. "/" .. rel
local f = io.open(full, "rb")
if not f then return nil end
local data = f:read("*a")
f:close()
local ext = rel:match("%.([^%.]+)$") or ""
local ct = MIME[ext:lower()] or "application/octet-stream"
return data, ct
end
-- Cache headers: hashed assets get long TTL; everything else no-store.
local function cache_header(path)
-- Vite fingerprints assets with a hash in the filename (e.g. index-Bx3Rk.js)
if path:match("/assets/") then
return "public, max-age=31536000, immutable"
end
return "no-store"
end
-- ── Register routes ───────────────────────────────────────────────────────────
router.post("/oauth/token", api.oauth_token)
router.get("/health", api.health)
router.get("/api/v1/images", api.list_images)
router.get("/api/v1/images/:id", api.get_image)
router.get("/api/v1/images/:id/preview", api.get_preview)
router.get("/api/v1/images/:id/full", api.get_full)
router.post("/api/v1/images/:id/refresh", api.refresh_cache)
router.post("/api/v1/images/:id/export", api.trigger_export)
router.get("/api/v1/images/:id/modules", api.list_modules)
router.get("/api/v1/images/:id/modules/:op", api.get_module)
router.patch("/api/v1/images/:id/modules/:op", api.patch_module)
router.get("/api/v1/tags", api.list_tags)
router.get("/api/v1/films", api.list_films)
-- ── HTTP helpers ──────────────────────────────────────────────────────────────
local STATUS_TEXT = {
[200] = "OK", [201] = "Created", [202] = "Accepted", [204] = "No Content",
[301] = "Moved Permanently",
[400] = "Bad Request", [401] = "Unauthorized", [403] = "Forbidden",
[404] = "Not Found", [405] = "Method Not Allowed",
[500] = "Internal Server Error", [504] = "Gateway Timeout",
}
local function url_decode(s)
return s:gsub("+", " "):gsub("%%(%x%x)", function(h) return string.char(tonumber(h, 16)) end)
end
-- Parse the raw HTTP request from a connected socket.
-- Returns a request table, or nil on parse/timeout error.
local function read_request(client)
client:settimeout(10)
local line, lerr = client:receive("*l")
if not line then return nil end
local method, raw_path = line:match("^(%S+)%s+(%S+)%s+HTTP/")
if not method then return nil end
-- Headers
local headers = {}
while true do
local hline, herr = client:receive("*l")
if not hline or hline == "" then break end
local name, value = hline:match("^([^:]+):%s*(.*)$")
if name then
-- normalise to lowercase, strip trailing whitespace
headers[name:lower():gsub("%s+$", "")] = value:gsub("%s+$", "")
end
end
-- Body
local body = ""
local clen = tonumber(headers["content-length"])
if clen and clen > 0 then
body = client:receive(math.min(clen, 4 * 1024 * 1024)) or "" -- 4 MiB cap
end
-- Split path from query string
local path, qs = raw_path:match("^([^?]*)%??(.*)")
-- Parse query parameters
local params = {}
for k, v in (qs or ""):gmatch("([^&=]+)=([^&]*)") do
params[url_decode(k)] = url_decode(v)
end
return {
method = method:upper(),
path = path,
raw_path = raw_path,
headers = headers,
body = body,
params = params,
path_params = {},
}
end
local CORS_HEADERS = {
["Access-Control-Allow-Origin"] = "*",
["Access-Control-Allow-Methods"] = "GET, POST, PATCH, PUT, DELETE, OPTIONS",
["Access-Control-Allow-Headers"] = "Authorization, Content-Type",
["Access-Control-Max-Age"] = "86400",
}
-- Write an HTTP response to the socket.
local function write_response(client, status, body, content_type, extra_headers)
body = body or ""
content_type = content_type or "application/json; charset=utf-8"
local phrase = STATUS_TEXT[status] or "Unknown"
local lines = {
string.format("HTTP/1.1 %d %s\r\n", status, phrase),
"Server: darktable-api/1.0\r\n",
"Content-Type: " .. content_type .. "\r\n",
"Content-Length: " .. #body .. "\r\n",
"Connection: close\r\n",
}
for k, v in pairs(CORS_HEADERS) do
lines[#lines+1] = k .. ": " .. v .. "\r\n"
end
if extra_headers then
for k, v in pairs(extra_headers) do
lines[#lines+1] = k .. ": " .. v .. "\r\n"
end
end
lines[#lines+1] = "\r\n"
lines[#lines+1] = body
client:send(table.concat(lines))
end
local json = require "json"
local function handle_client(client)
local req = read_request(client)
if not req then
write_response(client, 400, '{"error":"bad_request"}')
return
end
-- CORS preflight
if req.method == "OPTIONS" then
write_response(client, 204, "")
return
end
-- API / OAuth routes take priority over static files.
local handler, path_params = router.match(req.method, req.path)
if handler then
req.path_params = path_params or {}
local status, body, ct, extra = handler(req)
write_response(client, status, body, ct, extra)
return
end
-- Static file serving for the PWA (GET/HEAD only).
if req.method == "GET" or req.method == "HEAD" then
local data, ct = read_static(req.path)
if not data then
-- SPA fallback: any unknown path gets index.html so client-side routing works.
data, ct = read_static("/index.html")
end
if data then
local extra = { ["Cache-Control"] = cache_header(req.path) }
write_response(client, 200, req.method == "HEAD" and "" or data, ct, extra)
return
end
end
write_response(client, 404, json.encode({ error = "not_found", path = req.path }))
end
-- ── Main server loop ──────────────────────────────────────────────────────────
local port = tonumber(arg and arg[1]) or config.port
local host = (arg and arg[2]) or config.host
local server, serr = socket.bind(host, port)
if not server then
io.stderr:write("darktable-api: cannot bind " .. host .. ":" .. port .. ": " .. tostring(serr) .. "\n")
os.exit(1)
end
server:settimeout(0) -- non-blocking accept
io.write(string.format(
"darktable-api: listening on http://%s:%d\n" ..
" library : %s\n" ..
" cache : %s\n" ..
" web app : http://%s:%d/\n",
host, port, config.library_db, config.cache_dir, host, port))
io.flush()
while true do
local client, cerr = server:accept()
if client then
local ok, result = pcall(handle_client, client)
if not ok then
pcall(write_response, client, 500,
json.encode({ error = "internal_server_error", detail = tostring(result) }))
io.stderr:write("darktable-api: error: " .. tostring(result) .. "\n")
end
pcall(function() client:close() end)
else
-- No connection yet — yield CPU briefly.
socket.select(nil, nil, 0.05)
end
end

View File

@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# start-with-api.sh — Launch the darktable REST API server and darktable together.
#
# What it does:
# 1. Optionally configures darktable's luarc to load the bridge plugin.
# 2. Starts the standalone LuaJIT REST API server in the background.
# 3. Starts darktable in the foreground.
# 4. Stops the API server when darktable exits.
#
# Usage:
# ./start-with-api.sh [darktable-options...]
#
# Environment variables (all optional):
# DT_CLIENT_ID OAuth client ID (default: darktable-client)
# DT_CLIENT_SECRET OAuth client secret (default: changeme)
# DT_JWT_SECRET JWT signing secret (default: change-this-secret-in-production)
# DT_API_HOST Server bind address (default: 127.0.0.1)
# DT_API_PORT Server port (default: 7645)
# DT_LIBRARY darktable library DB (default: ~/.config/darktable/library.db)
# DT_REFRESH_QUEUE IPC queue file (default: /tmp/dt-api-refresh.queue)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
: "${DT_CLIENT_ID:=darktable-client}"
: "${DT_CLIENT_SECRET:=changeme}"
: "${DT_JWT_SECRET:=change-this-secret-in-production}"
: "${DT_API_HOST:=127.0.0.1}"
: "${DT_API_PORT:=7645}"
: "${DT_REFRESH_QUEUE:=/tmp/dt-api-refresh.queue}"
export DT_CLIENT_ID DT_CLIENT_SECRET DT_JWT_SECRET
export DT_API_HOST DT_API_PORT DT_REFRESH_QUEUE
# ── 1. Ensure the bridge plugin is in darktable's luarc ───────────────────────
LUARC="$HOME/.config/darktable/luarc"
BRIDGE_REQUIRE='require "contrib/darktable-api/darktable-api-bridge"'
if ! grep -qF "$BRIDGE_REQUIRE" "$LUARC" 2>/dev/null; then
echo "[start-with-api] Adding bridge plugin to $LUARC"
echo "$BRIDGE_REQUIRE" >> "$LUARC"
fi
# ── 2. Install the bridge plugin into darktable's lua directory ───────────────
# darktable searches $HOME/.config/darktable/lua/ for scripts.
# We copy (not symlink) the bridge plugin file there so darktable can
# require "contrib/darktable-api/darktable-api-bridge" without any extra path config.
DT_LUA_DIR="$HOME/.config/darktable/lua"
BRIDGE_DEST="$DT_LUA_DIR/contrib/darktable-api"
BRIDGE_SRC="$SCRIPT_DIR/darktable-api-bridge.lua"
mkdir -p "$BRIDGE_DEST"
if ! cmp -s "$BRIDGE_SRC" "$BRIDGE_DEST/darktable-api-bridge.lua" 2>/dev/null; then
cp "$BRIDGE_SRC" "$BRIDGE_DEST/darktable-api-bridge.lua"
echo "[start-with-api] Installed bridge plugin → $BRIDGE_DEST/"
fi
# ── 3. Clean up any stale queue file ─────────────────────────────────────────
rm -f "$DT_REFRESH_QUEUE" "${DT_REFRESH_QUEUE}.processing"
# ── 4. Start the LuaJIT REST API server ───────────────────────────────────────
API_LOG="${TMPDIR:-/tmp}/darktable-api.log"
echo "[start-with-api] Starting API server on http://${DT_API_HOST}:${DT_API_PORT}"
echo "[start-with-api] Log: $API_LOG"
luajit "$SCRIPT_DIR/server.lua" "$DT_API_PORT" "$DT_API_HOST" >"$API_LOG" 2>&1 &
API_PID=$!
# Give the server a moment to bind and confirm it's up.
sleep 0.5
if ! kill -0 "$API_PID" 2>/dev/null; then
echo "[start-with-api] ERROR: API server failed to start. Check $API_LOG" >&2
cat "$API_LOG" >&2
exit 1
fi
if ! curl -sf "http://${DT_API_HOST}:${DT_API_PORT}/health" >/dev/null 2>&1; then
echo "[start-with-api] WARNING: health check failed; server may still be starting"
fi
echo "[start-with-api] API server running (PID $API_PID)"
echo ""
echo " Get a token:"
echo " curl -X POST http://${DT_API_HOST}:${DT_API_PORT}/oauth/token \\"
echo " -d 'grant_type=client_credentials&client_id=${DT_CLIENT_ID}&client_secret=${DT_CLIENT_SECRET}'"
echo ""
echo " Trigger a cache refresh (replace TOKEN and IMAGE_ID):"
echo " curl -X POST http://${DT_API_HOST}:${DT_API_PORT}/api/v1/images/IMAGE_ID/refresh \\"
echo " -H 'Authorization: Bearer TOKEN'"
echo ""
# ── 5. Start darktable ────────────────────────────────────────────────────────
echo "[start-with-api] Starting darktable..."
darktable "$@" || true
# ── 6. Stop the API server on exit ───────────────────────────────────────────
echo ""
echo "[start-with-api] darktable exited; stopping API server (PID $API_PID)"
kill "$API_PID" 2>/dev/null || true
wait "$API_PID" 2>/dev/null || true
rm -f "$DT_REFRESH_QUEUE" "${DT_REFRESH_QUEUE}.processing"
echo "[start-with-api] done"

4
contrib/darktable-api/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
build/
dist/
.vite/

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0d0d0d" />
<meta name="description" content="darktable REST API web interface" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="darktable" />
<link rel="apple-touch-icon" href="/icon.svg" />
<title>darktable Gallery</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

6058
contrib/darktable-api/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
{
"name": "darktable-api-pwa",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^5.4.11",
"vite-plugin-pwa": "^0.21.1"
}
}

View File

@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!-- Dark background -->
<rect width="512" height="512" rx="80" fill="#0d0d0d"/>
<!-- Aperture blades in orange accent -->
<!-- Center circle -->
<circle cx="256" cy="256" r="60" fill="none" stroke="#f4a22d" stroke-width="8"/>
<!-- Aperture blade 1 -->
<path d="M256 120 L296 196 L256 196 Z" fill="#f4a22d" opacity="0.9"/>
<!-- Aperture blade 2 -->
<path d="M370 181 L316 240 L296 196 Z" fill="#f4a22d" opacity="0.9"/>
<!-- Aperture blade 3 -->
<path d="M392 296 L316 240 L336 296 Z" fill="#f4a22d" opacity="0.9"/>
<!-- Aperture blade 4 -->
<path d="M336 392 L256 316 L336 296 Z" fill="#f4a22d" opacity="0.9"/>
<!-- Aperture blade 5 -->
<path d="M176 392 L196 316 L256 316 Z" fill="#f4a22d" opacity="0.9"/>
<!-- Aperture blade 6 -->
<path d="M120 296 L196 316 L176 240 Z" fill="#f4a22d" opacity="0.9"/>
<!-- Aperture blade 7 -->
<path d="M120 181 L176 240 L196 196 Z" fill="#f4a22d" opacity="0.9"/>
<!-- Aperture blade 8 -->
<path d="M216 120 L216 196 L256 196 Z" fill="#f4a22d" opacity="0.9"/>
<!-- Outer ring -->
<circle cx="256" cy="256" r="180" fill="none" stroke="#f4a22d" stroke-width="12" opacity="0.6"/>
<!-- Inner highlight -->
<circle cx="256" cy="256" r="40" fill="#f4a22d" opacity="0.3"/>
<circle cx="256" cy="256" r="20" fill="#f4a22d"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,69 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useReducer,
} from 'react';
import { ToastProvider } from './components/Toast.jsx';
import { getSettings } from './api.js';
import Settings from './screens/Settings.jsx';
import Gallery from './screens/Gallery.jsx';
// ── App Context ───────────────────────────────────────────────────────────────
const AppContext = createContext(null);
export function useApp() {
const ctx = useContext(AppContext);
if (!ctx) throw new Error('useApp must be used within AppContext');
return ctx;
}
const initialState = {
screen: 'gallery', // 'settings' | 'gallery'
configured: false,
};
function reducer(state, action) {
switch (action.type) {
case 'INIT':
return { ...state, configured: action.configured, screen: action.configured ? 'gallery' : 'settings' };
case 'GO_SETTINGS':
return { ...state, screen: 'settings' };
case 'GO_GALLERY':
return { ...state, screen: 'gallery', configured: true };
default:
return state;
}
}
// ── App Root ──────────────────────────────────────────────────────────────────
export default function App() {
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
const settings = getSettings();
dispatch({ type: 'INIT', configured: !!settings });
}, []);
const goSettings = useCallback(() => dispatch({ type: 'GO_SETTINGS' }), []);
const goGallery = useCallback(() => dispatch({ type: 'GO_GALLERY' }), []);
const contextValue = { goSettings, goGallery, configured: state.configured };
return (
<AppContext.Provider value={contextValue}>
<ToastProvider>
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{state.screen === 'settings' ? (
<Settings />
) : (
<Gallery />
)}
</div>
</ToastProvider>
</AppContext.Provider>
);
}

View File

@@ -0,0 +1,353 @@
// Darktable REST API client
// Handles OAuth2 client_credentials, token caching, all endpoints.
const STORAGE_KEYS = {
SETTINGS: 'dt_settings',
TOKEN: 'dt_token',
};
// ── Settings ─────────────────────────────────────────────────────────────────
export function getSettings() {
try {
const raw = localStorage.getItem(STORAGE_KEYS.SETTINGS);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
export function saveSettings(settings) {
localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(settings));
}
export function clearSettings() {
localStorage.removeItem(STORAGE_KEYS.SETTINGS);
localStorage.removeItem(STORAGE_KEYS.TOKEN);
}
// ── Token management ──────────────────────────────────────────────────────────
function getCachedToken() {
try {
const raw = localStorage.getItem(STORAGE_KEYS.TOKEN);
if (!raw) return null;
const token = JSON.parse(raw);
// Treat as expired 30 seconds early to avoid race conditions
if (Date.now() >= token.expiresAt - 30_000) return null;
return token.accessToken;
} catch {
return null;
}
}
function cacheToken(accessToken, expiresIn) {
const token = {
accessToken,
expiresAt: Date.now() + expiresIn * 1000,
};
localStorage.setItem(STORAGE_KEYS.TOKEN, JSON.stringify(token));
}
let tokenFetchPromise = null;
async function fetchToken(settings) {
const url = `${settings.baseUrl}/oauth/token`;
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: settings.clientId,
client_secret: settings.clientSecret,
});
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new ApiError(res.status, `Token request failed: ${text || res.statusText}`);
}
const data = await res.json();
const expiresIn = data.expires_in ?? 3600;
cacheToken(data.access_token, expiresIn);
return data.access_token;
}
async function getToken() {
const settings = getSettings();
if (!settings) throw new ApiError(0, 'Not configured. Please open Settings.');
const cached = getCachedToken();
if (cached) return cached;
// Deduplicate concurrent token fetches
if (!tokenFetchPromise) {
tokenFetchPromise = fetchToken(settings).finally(() => {
tokenFetchPromise = null;
});
}
return tokenFetchPromise;
}
// ── Error class ───────────────────────────────────────────────────────────────
export class ApiError extends Error {
constructor(status, message) {
super(message);
this.status = status;
this.name = 'ApiError';
}
}
// ── Core fetch helper ─────────────────────────────────────────────────────────
async function apiFetch(path, options = {}) {
const settings = getSettings();
if (!settings) throw new ApiError(0, 'Not configured. Please open Settings.');
const token = await getToken();
const url = `${settings.baseUrl}${path}`;
const headers = {
Authorization: `Bearer ${token}`,
...options.headers,
};
const res = await fetch(url, { ...options, headers });
if (!res.ok) {
const text = await res.text().catch(() => '');
let message;
try {
const json = JSON.parse(text);
message = json.error || json.message || text;
} catch {
message = text || res.statusText;
}
throw new ApiError(res.status, message);
}
return res;
}
async function apiJson(path, options = {}) {
const res = await apiFetch(path, {
...options,
headers: { Accept: 'application/json', ...options.headers },
});
return res.json();
}
// ── Blob URL cache ────────────────────────────────────────────────────────────
// Keeps a reference-counted map of blob URLs so we don't recreate them for the
// same image+size combination within a session.
const blobCache = new Map(); // key → { url, refs }
export function releaseBlobUrl(key) {
const entry = blobCache.get(key);
if (!entry) return;
entry.refs -= 1;
if (entry.refs <= 0) {
URL.revokeObjectURL(entry.url);
blobCache.delete(key);
}
}
export async function getPreviewBlob(id, size = 'thumb') {
const key = `preview:${id}:${size}`;
const existing = blobCache.get(key);
if (existing) {
existing.refs += 1;
return { url: existing.url, key };
}
// Use raw fetch so we can inspect the status before assuming it's an image.
const settings = getSettings();
if (!settings) throw new ApiError(0, 'Not configured.');
const token = await getToken();
const res = await fetch(
`${settings.baseUrl}/api/v1/images/${id}/preview?size=${size}`,
{ headers: { Authorization: `Bearer ${token}` } },
);
// 202 means the server has enqueued thumbnail generation; client should retry.
if (res.status === 202) {
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '4', 10);
return { generating: true, retryAfter };
}
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new ApiError(res.status, text || res.statusText);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
blobCache.set(key, { url, refs: 1 });
return { url, key };
}
export async function getFullBlob(id) {
const key = `full:${id}`;
const existing = blobCache.get(key);
if (existing) {
existing.refs += 1;
return { url: existing.url, key };
}
const res = await apiFetch(`/api/v1/images/${id}/full`);
// May return 202 with Retry-After; caller handles polling
if (res.status === 202) {
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '2', 10);
return { pending: true, retryAfter };
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
blobCache.set(key, { url, refs: 1 });
return { url, key };
}
// ── Images ────────────────────────────────────────────────────────────────────
export async function listImages(params = {}) {
const qs = new URLSearchParams();
const allowed = [
'limit', 'offset', 'sort_by', 'sort_dir',
'rating_min', 'rating_max', 'film_id',
'from', 'to', 'search', 'tag',
];
for (const key of allowed) {
if (params[key] !== undefined && params[key] !== '' && params[key] !== null) {
qs.set(key, String(params[key]));
}
}
const path = `/api/v1/images${qs.toString() ? '?' + qs.toString() : ''}`;
return apiJson(path);
}
export async function getImage(id) {
return apiJson(`/api/v1/images/${id}`);
}
export async function refreshImage(id) {
return apiJson(`/api/v1/images/${id}/refresh`, { method: 'POST' });
}
export async function exportImage(id, { signal } = {}) {
// 1. Enqueue the export (POST returns 202 immediately).
await apiFetch(`/api/v1/images/${id}/export`, { method: 'POST', signal });
// 2. Poll GET /full until the JPEG is ready (up to 120 s).
const settings = getSettings();
const token = await getToken();
const deadline = Date.now() + 120_000;
while (Date.now() < deadline) {
if (signal?.aborted) throw new ApiError(0, 'Cancelled');
await new Promise(resolve => setTimeout(resolve, 2000));
if (signal?.aborted) throw new ApiError(0, 'Cancelled');
const res = await fetch(`${settings.baseUrl}/api/v1/images/${id}/full`, {
headers: { Authorization: `Bearer ${token}` },
signal,
});
if (res.ok) return res.blob();
if (res.status !== 202) {
const text = await res.text().catch(() => '');
throw new ApiError(res.status, text || res.statusText);
}
// 202 → still generating, loop again
}
throw new ApiError(504, 'Export timed out after 120 s');
}
// ── Modules ───────────────────────────────────────────────────────────────────
export async function listModules(imageId) {
return apiJson(`/api/v1/images/${imageId}/modules`);
}
export async function getModule(imageId, op) {
return apiJson(`/api/v1/images/${imageId}/modules/${op}`);
}
/**
* Patch a module and optionally request a preview render.
* Returns { preview: blobUrl } when previewSize is set and the server responds
* with image/jpeg, or { data: json } for plain JSON responses.
*
* The PATCH with preview can take up to 30 seconds.
*/
export async function patchModule(imageId, op, body, previewSize = null) {
const settings = getSettings();
if (!settings) throw new ApiError(0, 'Not configured.');
const token = await getToken();
let path = `/api/v1/images/${imageId}/modules/${op}`;
if (previewSize) path += `?preview=${previewSize}`;
const url = `${settings.baseUrl}${path}`;
const res = await fetch(url, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
Accept: 'application/json, image/jpeg',
},
body: JSON.stringify(body),
// 35s timeout implemented via AbortController below
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new ApiError(res.status, text || res.statusText);
}
const contentType = res.headers.get('Content-Type') ?? '';
if (contentType.includes('image/jpeg') || contentType.includes('image/')) {
const blob = await res.blob();
const url = URL.createObjectURL(blob);
return { previewUrl: url };
}
const data = await res.json();
return { data };
}
// ── Tags & Films ──────────────────────────────────────────────────────────────
export async function listTags() {
return apiJson('/api/v1/tags');
}
export async function listFilms() {
return apiJson('/api/v1/films');
}
// ── Health ────────────────────────────────────────────────────────────────────
export async function checkHealth(baseUrl) {
const url = `${baseUrl}/health`;
const res = await fetch(url, { method: 'GET' });
if (!res.ok) throw new ApiError(res.status, `Health check failed: ${res.statusText}`);
return res.json();
}
/**
* Test the full connection: health check + token fetch.
* Returns { ok: true } or throws ApiError.
*/
export async function testConnection(settings) {
await checkHealth(settings.baseUrl);
await fetchToken(settings);
return { ok: true };
}

View File

@@ -0,0 +1,235 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { listFilms, ApiError } from '../api.js';
const SORT_OPTIONS = [
{ value: 'datetime_taken:desc', label: 'Newest first' },
{ value: 'datetime_taken:asc', label: 'Oldest first' },
{ value: 'filename:asc', label: 'Filename A→Z' },
{ value: 'filename:desc', label: 'Filename Z→A' },
{ value: 'rating:desc', label: 'Top rated' },
];
const STAR_OPTIONS = [
{ value: '', label: 'Any rating' },
{ value: '1', label: '★ and up' },
{ value: '2', label: '★★ and up' },
{ value: '3', label: '★★★ and up' },
{ value: '4', label: '★★★★ and up' },
{ value: '5', label: '★★★★★ only' },
];
export default function FilterBar({ filters, onChange }) {
const [films, setFilms] = useState([]);
const [filmsError, setFilmsError] = useState(null);
const [expanded, setExpanded] = useState(false);
const searchRef = useRef(null);
const searchTimer = useRef(null);
useEffect(() => {
listFilms()
.then(data => setFilms(Array.isArray(data) ? data : (data.items ?? [])))
.catch(err => {
if (err instanceof ApiError && err.status === 0) return; // not configured yet
setFilmsError('Could not load film rolls');
});
}, []);
const handleSearchChange = useCallback((e) => {
const value = e.target.value;
// Debounce search by 400ms
clearTimeout(searchTimer.current);
searchTimer.current = setTimeout(() => {
onChange({ ...filters, search: value, offset: 0 });
}, 400);
}, [filters, onChange]);
const handleSortChange = useCallback((e) => {
const [sort_by, sort_dir] = e.target.value.split(':');
onChange({ ...filters, sort_by, sort_dir, offset: 0 });
}, [filters, onChange]);
const handleRatingChange = useCallback((e) => {
onChange({ ...filters, rating_min: e.target.value, offset: 0 });
}, [filters, onChange]);
const handleFilmChange = useCallback((e) => {
onChange({ ...filters, film_id: e.target.value, offset: 0 });
}, [filters, onChange]);
const currentSort = `${filters.sort_by ?? 'datetime_taken'}:${filters.sort_dir ?? 'desc'}`;
const activeFilterCount = [
filters.search,
filters.rating_min,
filters.film_id,
].filter(Boolean).length;
return (
<div style={styles.root}>
{/* Search row */}
<div style={styles.searchRow}>
<div style={styles.searchWrap}>
<span style={styles.searchIcon}>⌕</span>
<input
ref={searchRef}
type="search"
className="form-input"
placeholder="Search images…"
defaultValue={filters.search ?? ''}
onChange={handleSearchChange}
style={{ paddingLeft: 36, height: 40 }}
aria-label="Search images"
/>
</div>
<button
className="btn btn-ghost"
style={{
height: 40,
padding: '0 12px',
fontSize: '0.8125rem',
gap: 6,
color: expanded || activeFilterCount > 0 ? 'var(--color-accent)' : undefined,
position: 'relative',
}}
onClick={() => setExpanded(x => !x)}
aria-expanded={expanded}
aria-label="Toggle filters"
>
<span>⚙</span>
<span>Filters</span>
{activeFilterCount > 0 && (
<span style={styles.badge}>{activeFilterCount}</span>
)}
</button>
</div>
{/* Expanded filters */}
{expanded && (
<div style={styles.filterRow}>
<div style={{ flex: 1, minWidth: 140 }}>
<label className="form-label" style={{ marginBottom: 4, display: 'block', fontSize: '0.75rem' }}>
Sort by
</label>
<select
className="form-select"
style={{ height: 38, fontSize: '0.875rem' }}
value={currentSort}
onChange={handleSortChange}
aria-label="Sort order"
>
{SORT_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div style={{ flex: 1, minWidth: 130 }}>
<label className="form-label" style={{ marginBottom: 4, display: 'block', fontSize: '0.75rem' }}>
Min rating
</label>
<select
className="form-select"
style={{ height: 38, fontSize: '0.875rem' }}
value={filters.rating_min ?? ''}
onChange={handleRatingChange}
aria-label="Minimum star rating"
>
{STAR_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
{films.length > 0 && (
<div style={{ flex: 1, minWidth: 150 }}>
<label className="form-label" style={{ marginBottom: 4, display: 'block', fontSize: '0.75rem' }}>
Film roll
</label>
<select
className="form-select"
style={{ height: 38, fontSize: '0.875rem' }}
value={filters.film_id ?? ''}
onChange={handleFilmChange}
aria-label="Film roll"
>
<option value="">All rolls</option>
{films.map(f => (
<option key={f.id} value={f.id}>
{f.folder ?? f.id}
</option>
))}
</select>
</div>
)}
{activeFilterCount > 0 && (
<div style={{ alignSelf: 'flex-end', paddingBottom: 0 }}>
<button
className="btn btn-ghost"
style={{ height: 38, padding: '0 12px', fontSize: '0.8125rem', color: 'var(--color-danger)' }}
onClick={() => onChange({ ...filters, search: '', rating_min: '', film_id: '', offset: 0 })}
>
Clear
</button>
</div>
)}
</div>
)}
</div>
);
}
const styles = {
root: {
background: 'var(--color-surface)',
borderBottom: '1px solid var(--color-border)',
padding: '10px 12px',
display: 'flex',
flexDirection: 'column',
gap: 10,
position: 'sticky',
top: 'var(--header-height)',
zIndex: 'var(--z-filter)',
},
searchRow: {
display: 'flex',
gap: 8,
alignItems: 'center',
},
searchWrap: {
flex: 1,
position: 'relative',
},
searchIcon: {
position: 'absolute',
left: 10,
top: '50%',
transform: 'translateY(-50%)',
color: 'var(--color-text-muted)',
fontSize: '1.1rem',
pointerEvents: 'none',
lineHeight: 1,
},
filterRow: {
display: 'flex',
flexWrap: 'wrap',
gap: 10,
alignItems: 'flex-end',
paddingTop: 4,
},
badge: {
position: 'absolute',
top: 6,
right: 4,
minWidth: 16,
height: 16,
borderRadius: '9999px',
background: 'var(--color-accent)',
color: '#0d0d0d',
fontSize: '0.625rem',
fontWeight: 700,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 3px',
},
};

View File

@@ -0,0 +1,246 @@
import React, { useEffect, useRef, useState } from 'react';
import { getPreviewBlob, releaseBlobUrl } from '../api.js';
const COLOR_LABEL_COLORS = {
red: '#e05252',
yellow: '#f4c842',
green: '#52c06e',
blue: '#5299e0',
purple: '#a052c0',
};
function StarRating({ rating }) {
if (!rating || rating < 1) return null;
return (
<div style={{
color: 'var(--color-accent)',
fontSize: '0.6875rem',
lineHeight: 1,
textShadow: '0 1px 3px rgba(0,0,0,0.8)',
}}>
{'★'.repeat(Math.min(5, rating))}
</div>
);
}
function ColorDots({ labels }) {
if (!labels || labels.length === 0) return null;
return (
<div style={{ display: 'flex', gap: 3 }}>
{labels.map(label => (
<span
key={label}
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: COLOR_LABEL_COLORS[label] ?? '#aaa',
display: 'inline-block',
}}
title={label}
/>
))}
</div>
);
}
export default function ImageCard({ image, onClick }) {
const [blobUrl, setBlobUrl] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [generating, setGenerating] = useState(false);
const blobKeyRef = useRef(null);
const isMounted = useRef(true);
const retriesRef = useRef(0);
const retryTimerRef = useRef(null);
useEffect(() => {
isMounted.current = true;
retriesRef.current = 0;
setLoading(true);
setError(false);
setGenerating(false);
function loadPreview() {
getPreviewBlob(image.id, 'thumb')
.then((result) => {
if (!isMounted.current) {
if (result.key) releaseBlobUrl(result.key);
return;
}
if (result.generating) {
setLoading(false);
setGenerating(true);
if (retriesRef.current < 15) {
retriesRef.current += 1;
retryTimerRef.current = setTimeout(loadPreview, (result.retryAfter ?? 4) * 1000);
} else {
setGenerating(false);
setError(true);
}
return;
}
if (blobKeyRef.current) releaseBlobUrl(blobKeyRef.current);
blobKeyRef.current = result.key;
setBlobUrl(result.url);
setLoading(false);
setGenerating(false);
})
.catch(() => {
if (isMounted.current) {
setLoading(false);
setGenerating(false);
setError(true);
}
});
}
loadPreview();
return () => {
isMounted.current = false;
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
if (blobKeyRef.current) {
releaseBlobUrl(blobKeyRef.current);
blobKeyRef.current = null;
}
};
}, [image.id]);
// Compute aspect ratio for the thumbnail placeholder
const aspect = image.width && image.height
? Math.min(1.5, Math.max(0.6, image.height / image.width))
: 1;
return (
<button
onClick={onClick}
style={{
display: 'block',
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
cursor: 'pointer',
textAlign: 'left',
transition: 'transform var(--transition-fast), box-shadow var(--transition-fast)',
width: '100%',
padding: 0,
WebkitTapHighlightColor: 'transparent',
}}
aria-label={`Open ${image.filename}`}
type="button"
>
{/* Thumbnail container — padding-bottom trick for aspect ratio */}
<div style={{
position: 'relative',
width: '100%',
paddingBottom: `${aspect * 100}%`,
background: 'var(--color-surface-2)',
}}>
{(loading || generating) && (
<div style={{
position: 'absolute', inset: 0,
display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
gap: 4,
}}>
<span className="spinner" />
{generating && (
<span style={{ fontSize: '0.625rem', color: 'var(--color-text-muted)', opacity: 0.7 }}>
Generating…
</span>
)}
</div>
)}
{error && !loading && !generating && (
<div style={{
position: 'absolute', inset: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<span style={{ fontSize: '1.5rem', opacity: 0.4 }}>⊘</span>
</div>
)}
{blobUrl && !error && (
<img
src={blobUrl}
alt={image.filename}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
)}
{/* Bottom overlay: stars + color dots */}
<div style={{
position: 'absolute',
bottom: 4,
left: 4,
right: 4,
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'space-between',
gap: 4,
pointerEvents: 'none',
}}>
<StarRating rating={image.rating} />
<ColorDots labels={image.color_labels} />
</div>
{/* RAW badge */}
{image.is_raw && (
<div style={{
position: 'absolute',
top: 4,
right: 4,
background: 'rgba(0,0,0,0.65)',
color: 'var(--color-accent)',
fontSize: '0.625rem',
fontWeight: 700,
padding: '1px 5px',
borderRadius: 3,
letterSpacing: '0.04em',
}}>
RAW
</div>
)}
</div>
{/* Caption */}
<div style={{ padding: '6px 8px', display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{
fontSize: '0.75rem',
color: 'var(--color-text)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
}}>
{image.filename}
</span>
{image.datetime_taken && (
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
{formatDate(image.datetime_taken)}
</span>
)}
</div>
</button>
);
}
function formatDate(dt) {
if (!dt) return '';
try {
return new Date(dt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
} catch {
return dt;
}
}

View File

@@ -0,0 +1,71 @@
import React, { useEffect, useState } from 'react';
import { listModules, ApiError } from '../api.js';
import ModulePanel from './ModulePanel.jsx';
export default function ModuleList({ imageId, onPreviewUpdate }) {
const [modules, setModules] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!imageId) return;
setLoading(true);
setError(null);
setModules(null);
listModules(imageId)
.then(data => {
// API may return an array or { items: [] }
const list = Array.isArray(data) ? data : (data.items ?? data.modules ?? []);
setModules(list);
setLoading(false);
})
.catch(err => {
const msg = err instanceof ApiError ? err.message : String(err);
setError(msg);
setLoading(false);
});
}, [imageId]);
if (loading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 32 }}>
<span className="spinner spinner-lg" />
</div>
);
}
if (error) {
return (
<div className="error-banner" style={{ margin: '0 0 8px' }}>
<span>⚠</span>
<span>Modules: {error}</span>
</div>
);
}
if (!modules || modules.length === 0) {
return (
<div className="empty-state" style={{ padding: '24px' }}>
<span className="empty-state-icon">◻</span>
<p className="text-sm text-muted">No development modules found</p>
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<h3 style={{ fontSize: '0.875rem', color: 'var(--color-text-muted)', marginBottom: 4 }}>
Development modules ({modules.length})
</h3>
{modules.map((mod, idx) => (
<ModulePanel
key={`${mod.operation}-${mod.history_num ?? idx}`}
imageId={imageId}
module={mod}
onPreviewUpdate={onPreviewUpdate}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,401 @@
import React, { useCallback, useEffect, useReducer, useRef, useState } from 'react';
import { patchModule, ApiError } from '../api.js';
import { useToast } from './Toast.jsx';
// ── Known param schemas ───────────────────────────────────────────────────────
// Provides type and range hints for well-known module parameters.
// Fallback: treat everything as a generic number / string.
const MODULE_PARAMS = {
exposure: {
exposure: { type: 'float', min: -18, max: 18, step: 0.01, label: 'Exposure (EV)' },
black: { type: 'float', min: -1, max: 1, step: 0.001, label: 'Black point' },
mode: { type: 'int', label: 'Mode' },
},
temperature: {
red: { type: 'float', min: 0.1, max: 8, step: 0.01, label: 'Red' },
green: { type: 'float', min: 0.1, max: 8, step: 0.01, label: 'Green' },
blue: { type: 'float', min: 0.1, max: 8, step: 0.01, label: 'Blue' },
g2: { type: 'float', min: 0.1, max: 8, step: 0.01, label: 'Green2' },
},
filmicrgb: {
grey_point_source: { type: 'float', min: 0.1, max: 36, step: 0.01, label: 'Grey point src (%)' },
black_point_source: { type: 'float', min: -16, max: -0.1, step: 0.01, label: 'Black point src (EV)' },
white_point_source: { type: 'float', min: 0.1, max: 16, step: 0.01, label: 'White point src (EV)' },
contrast: { type: 'float', min: 0, max: 5, step: 0.01, label: 'Contrast' },
saturation: { type: 'float', min: 0, max: 4, step: 0.01, label: 'Saturation' },
balance: { type: 'float', min: -50, max: 50, step: 0.1, label: 'Shadows↔Highlights' },
output_power: { type: 'float', min: 0, max: 10, step: 0.01, label: 'Output power' },
latitude: { type: 'float', min: 0.01, max: 99, step: 0.1, label: 'Latitude (%)' },
noise_level: { type: 'float', min: 0, max: 0.5, step: 0.001, label: 'Noise level' },
},
highlights: {
mode: { type: 'int', label: 'Mode' },
strength: { type: 'float', min: 0, max: 1, step: 0.01, label: 'Strength' },
clip: { type: 'float', min: 0, max: 1, step: 0.001, label: 'Clip' },
noise_level: { type: 'float', min: 0, max: 1, step: 0.001, label: 'Noise level' },
iterations: { type: 'int', label: 'Iterations' },
},
channelmixerrgb: {
temperature: { type: 'float', min: 1667, max: 25000, step: 10, label: 'Temperature (K)' },
illuminant: { type: 'int', label: 'Illuminant' },
adaptation: { type: 'int', label: 'Adaptation' },
},
flip: {
orientation: { type: 'int', label: 'Orientation' },
},
gamma: {
gamma: { type: 'float', min: 0, max: 1, step: 0.001, label: 'Gamma' },
linear: { type: 'float', min: 0, max: 1, step: 0.001, label: 'Linear' },
},
rawprepare: {
left: { type: 'int', label: 'Left crop (px)' },
top: { type: 'int', label: 'Top crop (px)' },
right: { type: 'int', label: 'Right crop (px)' },
bottom: { type: 'int', label: 'Bottom crop (px)' },
raw_white_point: { type: 'int', label: 'Raw white point' },
},
demosaic: {
demosaicing_method: { type: 'int', label: 'Method' },
green_eq: { type: 'int', label: 'Green equalization' },
median_thrs: { type: 'float', min: 0, max: 1, step: 0.001, label: 'Median threshold' },
},
};
// ── Helpers ───────────────────────────────────────────────────────────────────
function inferType(key, value, opName) {
const schema = MODULE_PARAMS[opName]?.[key];
if (schema) return schema;
const numVal = Number(value);
if (typeof value === 'boolean') return { type: 'bool' };
if (!isNaN(numVal)) {
const isFloat = String(value).includes('.') || (Number.isFinite(numVal) && !Number.isInteger(numVal));
return isFloat
? { type: 'float', min: -100, max: 100, step: 0.01, label: key }
: { type: 'int', label: key };
}
return { type: 'string', label: key };
}
// ── ParamField ────────────────────────────────────────────────────────────────
function ParamField({ paramKey, value, schema, onChange }) {
const label = schema?.label ?? paramKey;
if (schema?.type === 'bool' || typeof value === 'boolean') {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, minHeight: 36 }}>
<span style={{ fontSize: '0.875rem', color: 'var(--color-text-muted)' }}>{label}</span>
<label className="toggle">
<input
type="checkbox"
checked={!!value}
onChange={e => onChange(paramKey, e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
);
}
if (schema?.type === 'float' && schema.min !== undefined && schema.max !== undefined) {
const numVal = Number(value);
const displayVal = Number.isNaN(numVal) ? 0 : numVal;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>{label}</span>
<span style={{ fontSize: '0.8125rem', fontFamily: 'var(--font-mono)', color: 'var(--color-text)' }}>
{displayVal.toFixed(schema.step < 0.01 ? 4 : schema.step < 0.1 ? 3 : 2)}
</span>
</div>
<input
type="range"
className="param-slider"
min={schema.min}
max={schema.max}
step={schema.step ?? 0.01}
value={displayVal}
onChange={e => onChange(paramKey, parseFloat(e.target.value))}
/>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ fontSize: '0.625rem', color: 'var(--color-text-faint)' }}>{schema.min}</span>
<span style={{ fontSize: '0.625rem', color: 'var(--color-text-faint)' }}>{schema.max}</span>
</div>
</div>
);
}
// int / string — text input
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minHeight: 36 }}>
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', flex: 1 }}>{label}</span>
<input
type="number"
className="form-input"
style={{ width: 90, height: 32, padding: '0 8px', fontSize: '0.8125rem' }}
value={value ?? ''}
step={schema?.type === 'float' ? (schema.step ?? 0.01) : 1}
onChange={e => {
const raw = e.target.value;
const num = Number(raw);
onChange(paramKey, Number.isNaN(num) ? raw : num);
}}
/>
</div>
);
}
// ── ModulePanel ───────────────────────────────────────────────────────────────
function paramsReducer(state, action) {
switch (action.type) {
case 'RESET':
return { ...action.params };
case 'SET':
return { ...state, [action.key]: action.value };
default:
return state;
}
}
export default function ModulePanel({ imageId, module: mod, onPreviewUpdate }) {
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [enabled, setEnabled] = useState(mod.enabled ?? true);
const [params, dispatchParams] = useReducer(paramsReducer, mod.params ?? {});
const [dirty, setDirty] = useState(false);
const [applying, setApplying] = useState(false);
const [renderState, setRenderState] = useState(null); // null | 'rendering' | 'done' | 'error'
const [previewUrl, setPreviewUrl] = useState(null);
const previewUrlRef = useRef(null);
// Sync when module prop changes (e.g., after gallery refresh)
useEffect(() => {
setEnabled(mod.enabled ?? true);
dispatchParams({ type: 'RESET', params: mod.params ?? {} });
setDirty(false);
}, [mod]);
// Cleanup preview blob URL on unmount
useEffect(() => {
return () => {
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current);
};
}, []);
function handleParamChange(key, value) {
dispatchParams({ type: 'SET', key, value });
setDirty(true);
}
function handleEnabledChange(newEnabled) {
setEnabled(newEnabled);
setDirty(true);
}
async function handleApply() {
setApplying(true);
setRenderState('rendering');
const body = {
enabled,
params,
};
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 35_000);
try {
const result = await patchModule(imageId, mod.operation, body, 'medium');
clearTimeout(timeoutId);
if (result.previewUrl) {
// Revoke previous preview blob
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = result.previewUrl;
setPreviewUrl(result.previewUrl);
onPreviewUpdate?.(result.previewUrl);
setRenderState('done');
toast(`${mod.operation}: applied`, 'success');
} else {
setRenderState('done');
toast(`${mod.operation}: applied (no preview)`, 'success');
}
setDirty(false);
} catch (err) {
clearTimeout(timeoutId);
if (err.name === 'AbortError') {
setRenderState('error');
toast(`${mod.operation}: render timed out`, 'error');
} else {
const msg = err instanceof ApiError ? err.message : String(err);
setRenderState('error');
toast(`${mod.operation}: ${msg}`, 'error');
}
} finally {
setApplying(false);
}
}
const paramEntries = Object.entries(params);
return (
<div style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
background: 'var(--color-surface-2)',
}}>
{/* Header row — always visible */}
<button
type="button"
onClick={() => setOpen(o => !o)}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '10px 14px',
background: 'none',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
minHeight: 44,
}}
aria-expanded={open}
>
{/* Enabled toggle — stop propagation so clicking it doesn't toggle open */}
<span
role="none"
onClick={e => {
e.stopPropagation();
handleEnabledChange(!enabled);
}}
style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}
title={enabled ? 'Disable module' : 'Enable module'}
>
<label className="toggle" style={{ pointerEvents: 'none' }}>
<input type="checkbox" readOnly checked={enabled} tabIndex={-1} />
<span className="toggle-track" />
</label>
</span>
<span style={{ flex: 1, fontSize: '0.9rem', fontWeight: 500, color: enabled ? 'var(--color-text)' : 'var(--color-text-muted)' }}>
{mod.multi_name ? `${mod.operation} · ${mod.multi_name}` : mod.operation}
</span>
{dirty && (
<span style={{
fontSize: '0.6875rem',
color: 'var(--color-accent)',
background: 'var(--color-accent-dim)',
padding: '1px 7px',
borderRadius: '9999px',
fontWeight: 600,
}}>
modified
</span>
)}
<span style={{
fontSize: '0.75rem',
color: 'var(--color-text-faint)',
transform: open ? 'rotate(180deg)' : 'rotate(0)',
transition: 'transform var(--transition-fast)',
display: 'inline-block',
}}>
▾
</span>
</button>
{/* Expanded body */}
{open && (
<div style={{ padding: '4px 14px 14px', display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ height: 1, background: 'var(--color-border)' }} />
{/* Module info row */}
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
{mod.history_num !== undefined && (
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-faint)' }}>
history #{mod.history_num}
</span>
)}
{mod.module_version !== undefined && (
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-faint)' }}>
v{mod.module_version}
</span>
)}
</div>
{/* Param fields */}
{paramEntries.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{paramEntries.map(([key, value]) => {
const schema = inferType(key, value, mod.operation);
return (
<ParamField
key={key}
paramKey={key}
value={value}
schema={schema}
onChange={handleParamChange}
/>
);
})}
</div>
) : (
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-faint)' }}>
No editable parameters
</p>
)}
{/* Apply button + render state */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<button
type="button"
className="btn btn-primary"
style={{ width: '100%', height: 40, fontSize: '0.875rem' }}
onClick={handleApply}
disabled={applying || (!dirty && renderState !== null)}
>
{applying ? (
<>
<span className="spinner" style={{ width: 16, height: 16 }} />
Rendering…
</>
) : 'Apply'}
</button>
{renderState === 'rendering' && (
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', textAlign: 'center' }}>
Waiting for preview render (up to 30 s)…
</p>
)}
{renderState === 'error' && (
<p style={{ fontSize: '0.75rem', color: 'var(--color-danger)', textAlign: 'center' }}>
Render failed or timed out.
</p>
)}
</div>
{/* Inline preview after apply */}
{previewUrl && renderState === 'done' && (
<div style={{ borderRadius: 'var(--radius-md)', overflow: 'hidden', border: '1px solid var(--color-border)' }}>
<img
src={previewUrl}
alt="Module preview"
style={{ width: '100%', height: 'auto', display: 'block' }}
/>
<p style={{ fontSize: '0.6875rem', color: 'var(--color-text-faint)', textAlign: 'center', padding: '4px 0' }}>
Preview after {mod.operation}
</p>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,133 @@
import React, { createContext, useCallback, useContext, useReducer, useRef } from 'react';
// ── Context ───────────────────────────────────────────────────────────────────
const ToastContext = createContext(null);
let nextId = 0;
function reducer(state, action) {
switch (action.type) {
case 'ADD':
return [...state, action.toast];
case 'REMOVE':
return state.filter(t => t.id !== action.id);
default:
return state;
}
}
export function ToastProvider({ children }) {
const [toasts, dispatch] = useReducer(reducer, []);
const timers = useRef({});
const dismiss = useCallback((id) => {
clearTimeout(timers.current[id]);
delete timers.current[id];
dispatch({ type: 'REMOVE', id });
}, []);
const toast = useCallback((message, type = 'info', duration = 4000) => {
const id = ++nextId;
dispatch({ type: 'ADD', toast: { id, message, type } });
if (duration > 0) {
timers.current[id] = setTimeout(() => dismiss(id), duration);
}
return id;
}, [dismiss]);
return (
<ToastContext.Provider value={{ toast, dismiss }}>
{children}
<ToastContainer toasts={toasts} dismiss={dismiss} />
</ToastContext.Provider>
);
}
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within ToastProvider');
return ctx;
}
// ── Rendering ─────────────────────────────────────────────────────────────────
const ICONS = {
info: 'ℹ',
success: '✓',
warning: '⚠',
error: '✕',
};
function ToastContainer({ toasts, dismiss }) {
if (toasts.length === 0) return null;
return (
<div style={{
position: 'fixed',
bottom: 'max(80px, calc(env(safe-area-inset-bottom, 0px) + 80px))',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 'var(--z-toast)',
display: 'flex',
flexDirection: 'column',
gap: '8px',
alignItems: 'center',
width: 'min(calc(100vw - 32px), 400px)',
pointerEvents: 'none',
}}>
{toasts.map(t => (
<ToastItem key={t.id} toast={t} dismiss={dismiss} />
))}
</div>
);
}
function ToastItem({ toast, dismiss }) {
const colorMap = {
info: 'var(--color-text)',
success: 'var(--color-success)',
warning: 'var(--color-warning)',
error: 'var(--color-danger)',
};
return (
<div
role="alert"
aria-live="polite"
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '10px',
padding: '12px 16px',
background: 'var(--color-surface-2)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-lg)',
boxShadow: 'var(--shadow-lg)',
fontSize: '0.9rem',
pointerEvents: 'all',
animation: 'toastIn 200ms ease',
maxWidth: '100%',
cursor: 'pointer',
userSelect: 'none',
}}
onClick={() => dismiss(toast.id)}
>
<span style={{ color: colorMap[toast.type] ?? colorMap.info, fontSize: '1rem', flexShrink: 0, lineHeight: 1.5 }}>
{ICONS[toast.type] ?? ICONS.info}
</span>
<span style={{ flex: 1, color: 'var(--color-text)', lineHeight: 1.5, wordBreak: 'break-word' }}>
{toast.message}
</span>
</div>
);
}
// Keyframe injected once
const style = document.createElement('style');
style.textContent = `
@keyframes toastIn {
from { opacity: 0; transform: translateY(12px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
`;
document.head.appendChild(style);

View File

@@ -0,0 +1,456 @@
/* ── CSS Custom Properties ───────────────────────────────────────────────── */
:root {
--color-bg: #0d0d0d;
--color-surface: #1a1a1a;
--color-surface-2: #242424;
--color-surface-3: #2e2e2e;
--color-border: #333333;
--color-accent: #f4a22d;
--color-accent-dim: rgba(244, 162, 45, 0.15);
--color-accent-hover: #f8b84a;
--color-text: #e0e0e0;
--color-text-muted: #888888;
--color-text-faint: #555555;
--color-danger: #e05252;
--color-success: #52c06e;
--color-warning: #f4a22d;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-full: 9999px;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.6);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.7);
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
--tap-target: 44px;
--header-height: 56px;
--filter-height: 56px;
--transition-fast: 150ms ease;
--transition-med: 250ms ease;
--transition-slow: 350ms cubic-bezier(0.4, 0, 0.2, 1);
--z-header: 100;
--z-filter: 90;
--z-backdrop: 200;
--z-sheet: 210;
--z-toast: 300;
}
/* ── Reset ───────────────────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
height: 100%;
font-size: 16px;
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
}
body {
height: 100%;
background-color: var(--color-bg);
color: var(--color-text);
font-family: var(--font-sans);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overscroll-behavior: none;
}
#root {
height: 100%;
display: flex;
flex-direction: column;
}
img {
display: block;
max-width: 100%;
}
button {
font-family: inherit;
cursor: pointer;
border: none;
background: none;
color: inherit;
}
input, select, textarea {
font-family: inherit;
font-size: inherit;
color: inherit;
}
a {
color: var(--color-accent);
text-decoration: none;
}
/* ── Typography ─────────────────────────────────────────────────────────── */
h1 { font-size: 1.5rem; font-weight: 700; line-height: 1.2; }
h2 { font-size: 1.25rem; font-weight: 600; line-height: 1.3; }
h3 { font-size: 1rem; font-weight: 600; }
h4 { font-size: 0.875rem; font-weight: 600; }
.text-sm { font-size: 0.875rem; }
.text-xs { font-size: 0.75rem; }
.text-muted { color: var(--color-text-muted); }
.text-accent { color: var(--color-accent); }
.text-mono { font-family: var(--font-mono); }
/* ── Utility classes ────────────────────────────────────────────────────── */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.justify-center { justify-content: center; }
.gap-1 { gap: 4px; }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }
.gap-4 { gap: 16px; }
.flex-1 { flex: 1; }
.w-full { width: 100%; }
.h-full { height: 100%; }
.overflow-hidden { overflow: hidden; }
.overflow-auto { overflow: auto; }
/* ── Shared component styles ─────────────────────────────────────────────── */
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: var(--tap-target);
padding: 0 18px;
border-radius: var(--radius-md);
font-size: 0.9375rem;
font-weight: 500;
transition: background-color var(--transition-fast), opacity var(--transition-fast);
user-select: none;
white-space: nowrap;
cursor: pointer;
border: none;
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn-primary {
background-color: var(--color-accent);
color: #0d0d0d;
font-weight: 700;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--color-accent-hover);
}
.btn-secondary {
background-color: var(--color-surface-3);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.btn-secondary:hover:not(:disabled) {
background-color: var(--color-surface-2);
}
.btn-ghost {
background-color: transparent;
color: var(--color-text-muted);
}
.btn-ghost:hover:not(:disabled) {
background-color: var(--color-surface-2);
color: var(--color-text);
}
.btn-danger {
background-color: var(--color-danger);
color: white;
}
.btn-icon {
padding: 0;
width: var(--tap-target);
height: var(--tap-target);
border-radius: var(--radius-md);
}
/* Form controls */
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-label {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-muted);
}
.form-input {
height: var(--tap-target);
padding: 0 12px;
background-color: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
font-size: 0.9375rem;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
width: 100%;
}
.form-input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-dim);
}
.form-input::placeholder {
color: var(--color-text-faint);
}
.form-select {
height: var(--tap-target);
padding: 0 32px 0 12px;
background-color: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
font-size: 0.9375rem;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
cursor: pointer;
width: 100%;
}
.form-select:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-dim);
}
/* Card */
.card {
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
/* Spinner */
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid var(--color-border);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
.spinner-lg {
width: 36px;
height: 36px;
border-width: 3px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Divider */
.divider {
height: 1px;
background-color: var(--color-border);
margin: 0;
}
/* Tag pill */
.tag-pill {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: 0.75rem;
background-color: var(--color-surface-3);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
/* Star display */
.stars {
display: inline-flex;
gap: 2px;
color: var(--color-accent);
font-size: 0.875rem;
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
color: var(--color-text-muted);
text-align: center;
}
.empty-state-icon {
font-size: 3rem;
opacity: 0.4;
}
/* Error state */
.error-banner {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 16px;
background-color: rgba(224, 82, 82, 0.12);
border: 1px solid rgba(224, 82, 82, 0.3);
border-radius: var(--radius-md);
color: #f08080;
font-size: 0.875rem;
}
/* Toggle switch */
.toggle {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
flex-shrink: 0;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.toggle-track {
position: absolute;
inset: 0;
background-color: var(--color-surface-3);
border-radius: var(--radius-full);
border: 1px solid var(--color-border);
transition: background-color var(--transition-fast), border-color var(--transition-fast);
cursor: pointer;
}
.toggle input:checked + .toggle-track {
background-color: var(--color-accent);
border-color: var(--color-accent);
}
.toggle-track::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
background-color: white;
border-radius: 50%;
transition: transform var(--transition-fast);
}
.toggle input:checked + .toggle-track::after {
transform: translateX(20px);
}
/* Slider */
.param-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border-radius: 2px;
background: var(--color-surface-3);
outline: none;
cursor: pointer;
}
.param-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--color-accent);
cursor: pointer;
border: 2px solid #0d0d0d;
box-shadow: 0 0 0 2px rgba(244, 162, 45, 0.3);
}
.param-slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--color-accent);
cursor: pointer;
border: 2px solid #0d0d0d;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 4px;
height: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-surface-3);
border-radius: 2px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-border);
}
/* Safe area insets for notched phones */
.safe-bottom {
padding-bottom: env(safe-area-inset-bottom, 0px);
}

View File

@@ -0,0 +1,11 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.jsx';
const root = createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,368 @@
import React, {
useCallback,
useEffect,
useReducer,
useRef,
useState,
} from 'react';
import { listImages, ApiError } from '../api.js';
import { useApp } from '../App.jsx';
import { useToast } from '../components/Toast.jsx';
import FilterBar from '../components/FilterBar.jsx';
import ImageCard from '../components/ImageCard.jsx';
import ImageDetail from './ImageDetail.jsx';
const PAGE_SIZE = 50;
// ── State machine for the image list ─────────────────────────────────────────
const initialListState = {
items: [],
total: 0,
loading: false,
loadingMore: false,
error: null,
offset: 0,
hasMore: true,
};
function listReducer(state, action) {
switch (action.type) {
case 'LOAD_START':
return { ...state, loading: true, error: null };
case 'LOAD_MORE_START':
return { ...state, loadingMore: true, error: null };
case 'LOAD_SUCCESS':
return {
...state,
loading: false,
loadingMore: false,
items: action.replace ? action.items : [...state.items, ...action.items],
total: action.total,
offset: action.offset + action.items.length,
hasMore: action.offset + action.items.length < action.total,
error: null,
};
case 'LOAD_ERROR':
return { ...state, loading: false, loadingMore: false, error: action.error };
case 'RESET':
return { ...initialListState };
default:
return state;
}
}
const defaultFilters = {
sort_by: 'datetime_taken',
sort_dir: 'desc',
search: '',
rating_min: '',
film_id: '',
};
// ── Pull-to-refresh ───────────────────────────────────────────────────────────
const PTR_THRESHOLD = 70; // px to trigger refresh
// ── Gallery ───────────────────────────────────────────────────────────────────
export default function Gallery() {
const { goSettings } = useApp();
const { toast } = useToast();
const [listState, dispatch] = useReducer(listReducer, initialListState);
const [filters, setFilters] = useState(defaultFilters);
const [selectedImage, setSelectedImage] = useState(null);
// Pull-to-refresh state
const [ptrY, setPtrY] = useState(0);
const [ptrActive, setPtrActive] = useState(false);
const touchStartY = useRef(null);
const scrollRef = useRef(null);
// Sentinel element for IntersectionObserver (infinite scroll)
const sentinelRef = useRef(null);
const observerRef = useRef(null);
// Current active fetch abort controller
const fetchControllerRef = useRef(null);
// ── Data loading ────────────────────────────────────────────────────────────
const loadPage = useCallback(async (currentOffset, replace) => {
// Cancel any previous fetch
if (fetchControllerRef.current) fetchControllerRef.current.abort();
const controller = new AbortController();
fetchControllerRef.current = controller;
dispatch({ type: replace ? 'LOAD_START' : 'LOAD_MORE_START' });
try {
const params = {
limit: PAGE_SIZE,
offset: currentOffset,
sort_by: filters.sort_by,
sort_dir: filters.sort_dir,
};
if (filters.search) params.search = filters.search;
if (filters.rating_min) params.rating_min = filters.rating_min;
if (filters.film_id) params.film_id = filters.film_id;
const data = await listImages(params);
if (controller.signal.aborted) return;
const items = Array.isArray(data) ? data : (data.items ?? []);
const total = data.total ?? items.length;
dispatch({
type: 'LOAD_SUCCESS',
items,
total,
offset: currentOffset,
replace,
});
} catch (err) {
if (controller.signal.aborted) return;
const msg = err instanceof ApiError ? err.message : String(err);
dispatch({ type: 'LOAD_ERROR', error: msg });
toast(msg, 'error');
}
}, [filters, toast]);
// Initial load and when filters change
useEffect(() => {
dispatch({ type: 'RESET' });
loadPage(0, true);
// Scroll to top
scrollRef.current?.scrollTo({ top: 0 });
}, [filters]); // eslint-disable-line react-hooks/exhaustive-deps
// Note: we intentionally omit loadPage from deps to avoid double-fires;
// filters change triggers reset + load, which is correct.
// ── IntersectionObserver for infinite scroll ────────────────────────────────
useEffect(() => {
observerRef.current?.disconnect();
observerRef.current = new IntersectionObserver(
(entries) => {
if (
entries[0].isIntersecting &&
!listState.loading &&
!listState.loadingMore &&
listState.hasMore
) {
loadPage(listState.offset, false);
}
},
{ rootMargin: '200px' }
);
if (sentinelRef.current) {
observerRef.current.observe(sentinelRef.current);
}
return () => observerRef.current?.disconnect();
}, [listState.loading, listState.loadingMore, listState.hasMore, listState.offset, loadPage]);
// ── Pull-to-refresh touch handlers ─────────────────────────────────────────
function handleTouchStart(e) {
if (scrollRef.current?.scrollTop === 0) {
touchStartY.current = e.touches[0].clientY;
}
}
function handleTouchMove(e) {
if (touchStartY.current === null) return;
const dy = e.touches[0].clientY - touchStartY.current;
if (dy > 0) {
setPtrY(Math.min(dy * 0.5, PTR_THRESHOLD + 20));
}
}
function handleTouchEnd() {
if (ptrY >= PTR_THRESHOLD) {
setPtrActive(true);
dispatch({ type: 'RESET' });
loadPage(0, true).finally(() => {
setPtrActive(false);
setPtrY(0);
});
} else {
setPtrY(0);
}
touchStartY.current = null;
}
// ── Filter handler ──────────────────────────────────────────────────────────
const handleFiltersChange = useCallback((newFilters) => {
setFilters(newFilters);
}, []);
// ── Render ──────────────────────────────────────────────────────────────────
const showEmpty = !listState.loading && !listState.error && listState.items.length === 0;
const showError = !!listState.error && listState.items.length === 0;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Fixed header */}
<header style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 12px',
height: 'var(--header-height)',
background: 'var(--color-surface)',
borderBottom: '1px solid var(--color-border)',
flexShrink: 0,
position: 'sticky',
top: 0,
zIndex: 'var(--z-header)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<img src="/icon.svg" alt="" style={{ width: 28, height: 28, borderRadius: 6 }} />
<span style={{ fontWeight: 700, fontSize: '1rem', color: 'var(--color-accent)' }}>
darktable
</span>
{listState.total > 0 && (
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>
{listState.total.toLocaleString()} images
</span>
)}
</div>
<button
type="button"
className="btn btn-ghost btn-icon"
onClick={goSettings}
aria-label="Settings"
title="Settings"
style={{ fontSize: '1.25rem' }}
>
⚙
</button>
</header>
{/* Filter bar */}
<FilterBar filters={filters} onChange={handleFiltersChange} />
{/* Scrollable gallery area */}
<div
ref={scrollRef}
style={{ flex: 1, overflowY: 'auto', overscrollBehavior: 'contain', position: 'relative' }}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{/* Pull-to-refresh indicator */}
{ptrY > 10 && (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: Math.min(ptrY, PTR_THRESHOLD + 20),
color: ptrY >= PTR_THRESHOLD ? 'var(--color-accent)' : 'var(--color-text-muted)',
fontSize: '0.875rem',
gap: 8,
transition: ptrActive ? 'height var(--transition-med)' : 'none',
}}>
{ptrActive
? <><span className="spinner" style={{ width: 16, height: 16 }} /> Refreshing…</>
: (ptrY >= PTR_THRESHOLD ? '↑ Release to refresh' : '↓ Pull to refresh')
}
</div>
)}
{/* Initial loading */}
{listState.loading && listState.items.length === 0 && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 64, gap: 12 }}>
<span className="spinner spinner-lg" />
</div>
)}
{/* Error (no items) */}
{showError && (
<div style={{ padding: '24px 16px' }}>
<div className="error-banner">
<span>⚠</span>
<div>
<p>{listState.error}</p>
<button
type="button"
className="btn btn-secondary"
style={{ marginTop: 10, height: 36, fontSize: '0.875rem' }}
onClick={() => loadPage(0, true)}
>
Retry
</button>
</div>
</div>
</div>
)}
{/* Empty state */}
{showEmpty && (
<div className="empty-state">
<span className="empty-state-icon">◻</span>
<p>No images found</p>
<p className="text-sm text-muted">Try adjusting your filters</p>
</div>
)}
{/* Image grid */}
{listState.items.length > 0 && (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: 6,
padding: 6,
}}>
{listState.items.map(img => (
<ImageCard
key={img.id}
image={img}
onClick={() => setSelectedImage(img)}
/>
))}
</div>
)}
{/* Infinite scroll sentinel */}
<div
ref={sentinelRef}
style={{ height: 1 }}
aria-hidden="true"
/>
{/* Load-more spinner */}
{listState.loadingMore && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '20px 0' }}>
<span className="spinner" />
</div>
)}
{/* End-of-list indicator */}
{!listState.hasMore && listState.items.length > 0 && (
<p style={{
textAlign: 'center',
padding: '16px 0',
fontSize: '0.8125rem',
color: 'var(--color-text-faint)',
}}>
All {listState.total.toLocaleString()} images loaded
</p>
)}
</div>
{/* Image detail bottom sheet */}
{selectedImage && (
<ImageDetail
image={selectedImage}
onClose={() => setSelectedImage(null)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,443 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { getPreviewBlob, releaseBlobUrl, refreshImage, exportImage, ApiError } from '../api.js';
import { useToast } from '../components/Toast.jsx';
import ModuleList from '../components/ModuleList.jsx';
const PREVIEW_SIZES = ['thumb', 'small', 'medium', 'large', 'xlarge'];
// ── EXIF metadata rows ────────────────────────────────────────────────────────
function MetaRow({ label, value }) {
if (!value && value !== 0) return null;
return (
<div style={{ display: 'flex', gap: 8, padding: '5px 0' }}>
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', minWidth: 120, flexShrink: 0 }}>
{label}
</span>
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text)', wordBreak: 'break-word' }}>
{String(value)}
</span>
</div>
);
}
// ── Preview area ──────────────────────────────────────────────────────────────
function PreviewImage({ imageId, size, onModulePreview, modulePreviewUrl }) {
const [blobUrl, setBlobUrl] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const blobKeyRef = useRef(null);
const isMounted = useRef(true);
useEffect(() => {
isMounted.current = true;
setLoading(true);
setError(false);
getPreviewBlob(imageId, size)
.then(({ url, key }) => {
if (!isMounted.current) { releaseBlobUrl(key); return; }
if (blobKeyRef.current) releaseBlobUrl(blobKeyRef.current);
blobKeyRef.current = key;
setBlobUrl(url);
setLoading(false);
})
.catch(() => {
if (isMounted.current) { setLoading(false); setError(true); }
});
return () => {
isMounted.current = false;
if (blobKeyRef.current) { releaseBlobUrl(blobKeyRef.current); blobKeyRef.current = null; }
};
}, [imageId, size]);
const displayUrl = modulePreviewUrl ?? blobUrl;
return (
<div style={{
width: '100%',
minHeight: 200,
background: '#111',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
}}>
{loading && !displayUrl && (
<span className="spinner spinner-lg" />
)}
{error && !displayUrl && (
<div style={{ textAlign: 'center', color: 'var(--color-text-muted)' }}>
<div style={{ fontSize: '2rem', marginBottom: 8 }}>⊘</div>
<p className="text-sm">Preview unavailable</p>
</div>
)}
{displayUrl && (
<img
src={displayUrl}
alt="Image preview"
style={{ maxWidth: '100%', maxHeight: '60vh', objectFit: 'contain', display: 'block' }}
/>
)}
{modulePreviewUrl && (
<div style={{
position: 'absolute',
top: 8, right: 8,
background: 'rgba(244, 162, 45, 0.9)',
color: '#000',
fontSize: '0.6875rem',
fontWeight: 700,
padding: '2px 8px',
borderRadius: '9999px',
}}>
MODULE PREVIEW
</div>
)}
</div>
);
}
// ── Main component ────────────────────────────────────────────────────────────
export default function ImageDetail({ image, onClose }) {
const { toast } = useToast();
const [previewSizeIdx, setPreviewSizeIdx] = useState(2); // 'medium'
const [tab, setTab] = useState('info'); // 'info' | 'modules'
const [actionBusy, setActionBusy] = useState(null); // 'refresh' | 'export' | null
const [modulePreviewUrl, setModulePreviewUrl] = useState(null);
const modulePreviewUrlRef = useRef(null);
// Animate in
const [visible, setVisible] = useState(false);
useEffect(() => {
const t = requestAnimationFrame(() => setVisible(true));
return () => cancelAnimationFrame(t);
}, []);
// Cleanup module preview blob
useEffect(() => {
return () => {
if (modulePreviewUrlRef.current) URL.revokeObjectURL(modulePreviewUrlRef.current);
};
}, []);
function cyclePreviewSize() {
setPreviewSizeIdx(i => (i + 1) % PREVIEW_SIZES.length);
setModulePreviewUrl(null); // clear module preview when switching size
}
function handleModulePreview(url) {
if (modulePreviewUrlRef.current) URL.revokeObjectURL(modulePreviewUrlRef.current);
modulePreviewUrlRef.current = url;
setModulePreviewUrl(url);
}
async function handleRefresh() {
setActionBusy('refresh');
try {
await refreshImage(image.id);
toast('Thumbnails regenerated', 'success');
} catch (err) {
toast(err instanceof ApiError ? err.message : String(err), 'error');
} finally {
setActionBusy(null);
}
}
const exportAbortRef = useRef(null);
async function handleExport() {
// Cancel any in-progress export if the button is clicked again.
exportAbortRef.current?.abort();
const controller = new AbortController();
exportAbortRef.current = controller;
setActionBusy('export');
try {
const blob = await exportImage(image.id, { signal: controller.signal });
const stem = image.filename?.replace(/\.[^.]+$/, '') ?? 'export';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${stem}.jpg`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast('Export downloaded', 'success');
} catch (e) {
if (e instanceof ApiError && e.message === 'Cancelled') return;
toast(e instanceof ApiError ? e.message : String(e), 'error');
} finally {
setActionBusy(null);
}
}
function handleBackdropClick(e) {
if (e.target === e.currentTarget) onClose();
}
const currentSize = PREVIEW_SIZES[previewSizeIdx];
const meta = image.metadata ?? {};
return (
<>
{/* Backdrop */}
<div
onClick={handleBackdropClick}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.7)',
zIndex: 'var(--z-backdrop)',
transition: 'opacity var(--transition-slow)',
opacity: visible ? 1 : 0,
}}
aria-hidden="true"
/>
{/* Bottom sheet */}
<div
role="dialog"
aria-modal="true"
aria-label={image.filename}
style={{
position: 'fixed',
left: 0,
right: 0,
bottom: 0,
zIndex: 'var(--z-sheet)',
background: 'var(--color-surface)',
borderRadius: 'var(--radius-xl) var(--radius-xl) 0 0',
boxShadow: 'var(--shadow-lg)',
maxHeight: '92dvh',
display: 'flex',
flexDirection: 'column',
transform: visible ? 'translateY(0)' : 'translateY(100%)',
transition: 'transform var(--transition-slow)',
overflowY: 'hidden',
}}
>
{/* Drag handle */}
<div style={{ display: 'flex', justifyContent: 'center', padding: '12px 0 4px' }}>
<div style={{
width: 40, height: 4,
borderRadius: 2,
background: 'var(--color-border)',
}} />
</div>
{/* Sheet header */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px 16px 12px',
borderBottom: '1px solid var(--color-border)',
gap: 12,
flexShrink: 0,
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<h2 style={{
fontSize: '1rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{image.filename}
</h2>
{image.path && (
<p style={{
fontSize: '0.75rem',
color: 'var(--color-text-muted)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
marginTop: 2,
}}>
{image.path}
</p>
)}
</div>
<button
type="button"
className="btn btn-ghost btn-icon"
onClick={onClose}
aria-label="Close"
style={{ flexShrink: 0 }}
>
✕
</button>
</div>
{/* Scrollable content */}
<div style={{ flex: 1, overflowY: 'auto', overscrollBehavior: 'contain' }}>
{/* Preview image — tap to cycle sizes */}
<button
type="button"
onClick={cyclePreviewSize}
title={`Preview size: ${currentSize} (tap to cycle)`}
style={{
display: 'block',
width: '100%',
background: '#0a0a0a',
border: 'none',
cursor: 'pointer',
padding: 0,
position: 'relative',
}}
>
<PreviewImage
imageId={image.id}
size={currentSize}
modulePreviewUrl={modulePreviewUrl}
/>
<div style={{
position: 'absolute',
bottom: 8,
right: 8,
background: 'rgba(0,0,0,0.65)',
color: 'var(--color-text-muted)',
fontSize: '0.6875rem',
padding: '2px 8px',
borderRadius: '9999px',
pointerEvents: 'none',
}}>
{currentSize} · tap to cycle
</div>
</button>
{/* Action buttons */}
<div style={{ display: 'flex', gap: 10, padding: '12px 16px' }}>
<button
type="button"
className="btn btn-secondary"
style={{ flex: 1, height: 40, fontSize: '0.875rem' }}
onClick={handleRefresh}
disabled={actionBusy !== null}
>
{actionBusy === 'refresh'
? <><span className="spinner" style={{ width: 14, height: 14 }} /> Refreshing…</>
: '↺ Refresh'}
</button>
<button
type="button"
className="btn btn-secondary"
style={{ flex: 1, height: 40, fontSize: '0.875rem' }}
onClick={handleExport}
disabled={actionBusy !== null}
>
{actionBusy === 'export'
? <><span className="spinner" style={{ width: 14, height: 14 }} /> Exporting…</>
: '⬇ Export'}
</button>
</div>
{/* Tab switcher */}
<div style={{
display: 'flex',
borderBottom: '1px solid var(--color-border)',
padding: '0 16px',
gap: 4,
}}>
{['info', 'modules'].map(t => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
style={{
background: 'none',
border: 'none',
borderBottom: `2px solid ${tab === t ? 'var(--color-accent)' : 'transparent'}`,
padding: '10px 16px',
fontSize: '0.9rem',
fontWeight: tab === t ? 600 : 400,
color: tab === t ? 'var(--color-accent)' : 'var(--color-text-muted)',
cursor: 'pointer',
transition: 'color var(--transition-fast), border-color var(--transition-fast)',
minHeight: 44,
}}
>
{t === 'info' ? 'Info' : 'Modules'}
</button>
))}
</div>
{/* Tab content */}
<div style={{ padding: '16px' }}>
{tab === 'info' && (
<InfoTab image={image} meta={meta} />
)}
{tab === 'modules' && (
<ModuleList
imageId={image.id}
onPreviewUpdate={handleModulePreview}
/>
)}
</div>
{/* Safe area bottom padding */}
<div style={{ height: 'max(16px, env(safe-area-inset-bottom, 16px))' }} />
</div>
</div>
</>
);
}
// ── Info tab ──────────────────────────────────────────────────────────────────
function InfoTab({ image, meta }) {
const stars = image.rating > 0 ? '★'.repeat(image.rating) : '—';
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Tags */}
{image.tags && image.tags.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, paddingBottom: 12 }}>
{image.tags.map(tag => (
<span key={tag} className="tag-pill">{tag}</span>
))}
</div>
)}
<div style={{ height: 1, background: 'var(--color-border)', margin: '4px 0 8px' }} />
<MetaRow label="Rating" value={stars} />
<MetaRow label="Date taken" value={image.datetime_taken} />
<MetaRow label="Dimensions" value={image.width && image.height ? `${image.width} × ${image.height}` : null} />
<MetaRow label="Camera" value={[image.maker, image.model].filter(Boolean).join(' ')} />
<MetaRow label="Type" value={image.is_raw ? 'RAW' : 'JPEG/other'} />
<MetaRow label="Film roll" value={image.film_name ?? image.film_id} />
{/* Color labels */}
{image.color_labels && image.color_labels.length > 0 && (
<div style={{ display: 'flex', gap: 8, padding: '5px 0', alignItems: 'center' }}>
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', minWidth: 120 }}>
Color labels
</span>
<div style={{ display: 'flex', gap: 6 }}>
{image.color_labels.map(l => (
<span key={l} className="tag-pill" style={{ textTransform: 'capitalize' }}>{l}</span>
))}
</div>
</div>
)}
{/* Extra metadata fields from image.metadata object */}
{Object.keys(meta).length > 0 && (
<>
<div style={{ height: 1, background: 'var(--color-border)', margin: '8px 0 4px' }} />
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-faint)', marginBottom: 4 }}>
Additional metadata
</p>
{Object.entries(meta).map(([key, value]) => (
<MetaRow key={key} label={key} value={value} />
))}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,267 @@
import React, { useEffect, useReducer } from 'react';
import { useApp } from '../App.jsx';
import { getSettings, saveSettings, testConnection, ApiError } from '../api.js';
import { useToast } from '../components/Toast.jsx';
const initialForm = {
baseUrl: '',
clientId: '',
clientSecret: '',
};
function formReducer(state, action) {
switch (action.type) {
case 'SET_FIELD':
return { ...state, [action.field]: action.value };
case 'LOAD':
return { ...action.data };
default:
return state;
}
}
const statusIdle = { kind: 'idle' };
const statusTesting = { kind: 'testing' };
const statusOk = { kind: 'ok' };
const statusErr = (msg) => ({ kind: 'error', msg });
export default function Settings() {
const { goGallery } = useApp();
const { toast } = useToast();
const [form, dispatchForm] = useReducer(formReducer, initialForm);
const [status, setStatus] = React.useState(statusIdle);
useEffect(() => {
const s = getSettings();
if (s) {
dispatchForm({
type: 'LOAD',
data: {
baseUrl: s.baseUrl ?? '',
clientId: s.clientId ?? '',
clientSecret: s.clientSecret ?? '',
},
});
}
}, []);
function setField(field) {
return (e) => dispatchForm({ type: 'SET_FIELD', field, value: e.target.value });
}
async function handleTest(e) {
e.preventDefault();
setStatus(statusTesting);
const settings = {
baseUrl: form.baseUrl.replace(/\/$/, ''),
clientId: form.clientId.trim(),
clientSecret: form.clientSecret.trim(),
};
try {
await testConnection(settings);
setStatus(statusOk);
toast('Connection successful', 'success');
} catch (err) {
const msg = err instanceof ApiError ? err.message : String(err);
setStatus(statusErr(msg));
}
}
async function handleSave(e) {
e.preventDefault();
setStatus(statusTesting);
const settings = {
baseUrl: form.baseUrl.replace(/\/$/, ''),
clientId: form.clientId.trim(),
clientSecret: form.clientSecret.trim(),
};
try {
await testConnection(settings);
saveSettings(settings);
setStatus(statusOk);
toast('Settings saved', 'success');
goGallery();
} catch (err) {
const msg = err instanceof ApiError ? err.message : String(err);
setStatus(statusErr(msg));
}
}
const isBusy = status.kind === 'testing';
const isValid = form.baseUrl.trim() && form.clientId.trim() && form.clientSecret.trim();
return (
<div style={styles.root}>
{/* Header */}
<header style={styles.header}>
<div style={styles.logoRow}>
<img src="/icon.svg" alt="" style={styles.logo} />
<div>
<h1 style={{ fontSize: '1.25rem' }}>darktable Gallery</h1>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', marginTop: 2 }}>
API connection settings
</p>
</div>
</div>
</header>
{/* Form */}
<main style={styles.main}>
<form onSubmit={handleSave} style={styles.form} noValidate>
<div className="card" style={{ padding: '24px', display: 'flex', flexDirection: 'column', gap: '20px' }}>
<div className="form-group">
<label className="form-label" htmlFor="baseUrl">
API Base URL
</label>
<input
id="baseUrl"
className="form-input"
type="url"
placeholder="http://localhost:8080"
value={form.baseUrl}
onChange={setField('baseUrl')}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
/>
<p className="text-xs text-muted">
Leave empty to use same-origin (when served by the Lua server)
</p>
</div>
<div className="form-group">
<label className="form-label" htmlFor="clientId">
Client ID
</label>
<input
id="clientId"
className="form-input"
type="text"
placeholder="my-client"
value={form.clientId}
onChange={setField('clientId')}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="clientSecret">
Client Secret
</label>
<input
id="clientSecret"
className="form-input"
type="password"
placeholder="••••••••"
value={form.clientSecret}
onChange={setField('clientSecret')}
/>
</div>
{status.kind === 'error' && (
<div className="error-banner">
<span>⚠</span>
<span>{status.msg}</span>
</div>
)}
{status.kind === 'ok' && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '12px 16px',
background: 'rgba(82, 192, 110, 0.1)',
border: '1px solid rgba(82, 192, 110, 0.3)',
borderRadius: 'var(--radius-md)',
color: 'var(--color-success)',
fontSize: '0.875rem',
}}>
<span>✓</span>
<span>Connection verified</span>
</div>
)}
</div>
<div style={{ display: 'flex', gap: 12 }}>
<button
type="button"
className="btn btn-secondary"
style={{ flex: 1 }}
onClick={handleTest}
disabled={isBusy || !isValid}
>
{isBusy ? (
<>
<span className="spinner" style={{ width: 16, height: 16 }} />
Testing…
</>
) : 'Test Connection'}
</button>
<button
type="submit"
className="btn btn-primary"
style={{ flex: 1 }}
disabled={isBusy || !isValid}
>
{isBusy ? (
<>
<span className="spinner" style={{ width: 16, height: 16 }} />
Saving…
</>
) : 'Save & Connect'}
</button>
</div>
<p className="text-xs text-muted" style={{ textAlign: 'center', lineHeight: 1.6 }}>
Credentials are stored only in your browser's localStorage and
are never sent anywhere except the configured API URL.
</p>
</form>
</main>
</div>
);
}
const styles = {
root: {
flex: 1,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflowY: 'auto',
},
header: {
padding: '24px 20px 0',
},
logoRow: {
display: 'flex',
alignItems: 'center',
gap: 14,
marginBottom: 32,
},
logo: {
width: 48,
height: 48,
borderRadius: 12,
},
main: {
flex: 1,
padding: '0 20px 32px',
maxWidth: 480,
width: '100%',
margin: '0 auto',
},
form: {
display: 'flex',
flexDirection: 'column',
gap: 20,
},
};

View File

@@ -0,0 +1,81 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
root: '.',
build: {
outDir: 'build',
emptyOutDir: true,
},
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^.*\/api\/v1\/images\?/,
handler: 'NetworkFirst',
options: {
cacheName: 'api-images-list',
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 5,
},
networkTimeoutSeconds: 10,
},
},
{
urlPattern: /^.*\/api\/v1\/images\/\d+\/preview/,
handler: 'CacheFirst',
options: {
cacheName: 'image-previews',
expiration: {
maxEntries: 500,
maxAgeSeconds: 60 * 60 * 24,
},
},
},
{
urlPattern: /^.*\/api\/v1\/(tags|films)/,
handler: 'NetworkFirst',
options: {
cacheName: 'api-metadata',
expiration: {
maxEntries: 20,
maxAgeSeconds: 60 * 10,
},
networkTimeoutSeconds: 10,
},
},
],
},
manifest: {
name: 'darktable Gallery',
short_name: 'darktable',
description: 'darktable REST API web interface',
theme_color: '#0d0d0d',
background_color: '#0d0d0d',
display: 'standalone',
orientation: 'any',
start_url: '/',
icons: [
{
src: 'icon.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'any maskable',
},
],
},
devOptions: {
enabled: false,
},
}),
],
server: {
port: 5173,
},
});

View File

@@ -63,10 +63,8 @@ du.check_min_api_version("7.0.0", MODULE) -- choose the minimum version that c
local gettext = dt.gettext.gettext
dt.gettext.bindtextdomain(MODULE , dt.configuration.config_dir .. "/lua/locale/")
local function _(msgid)
return gettext(MODULE, msgid)
return gettext(msgid)
end
@@ -82,7 +80,7 @@ script_data.restart = nil -- how to restart the (lib) script after it'
script_data.show = nil -- only required for libs since the destroy_method only hides them
script_data.metadata = {
name = "dbmaint",
name = _("db maintenance"),
purpose = _("perform database maintenance"),
author = "Bill Ferguson <wpferguson@gamil.com>",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/dbmaint/"
@@ -117,6 +115,20 @@ local namespace = dbmaint
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
-------------------
-- helper functions
-------------------
local function set_log_level(level)
local old_log_level = log.log_level()
log.log_level(level)
return old_log_level
end
local function restore_log_level(level)
log.log_level(level)
end
local function scan_film_rolls()
local missing_films = {}
@@ -130,19 +142,21 @@ local function scan_film_rolls()
end
local function scan_images(film)
local old_log_level = set_log_level(DEFAULT_LOG_LEVEL)
local missing_images = {}
if film then
for i = 1, #film do
local image = film[i]
log.log_msg(log.debug, "checking " .. image.filename)
log.msg(log.debug, "checking " .. image.filename)
if not df.check_if_file_exists(image.path .. PS .. image.filename) then
log.log_msg(log.info, image.filename .. " not found")
log.msg(log.info, image.filename .. " not found")
table.insert(missing_images, image)
end
end
end
restore_log_level(old_log_level)
return missing_images
end
@@ -195,21 +209,23 @@ dbmaint.list_widget = dt.new_widget("text_view"){
}
dbmaint.chooser = dt.new_widget("combobox"){
label = "scan for",
label = _("scan for"),
selected = 1,
"film rolls", "images",
_("film rolls"), _("images"),
reset_callback = function(this)
this.selected = 1
end
}
dbmaint.scan_button = dt.new_widget("button"){
label = "scan",
tooltip = "click to scan for missing film rolls/files",
label = _("scan"),
tooltip = _("click to scan for missing film rolls/files"),
clicked_callback = function(this)
local found = nil
local found_text = ""
if dbmaint.chooser.value == "film rolls" then
local old_log_level = set_log_level(DEFAULT_LOG_LEVEL)
log.msg(log.debug, "Button clicked")
if dbmaint.chooser.selected == 1 then -- film rolls
found = scan_film_rolls()
if #found > 0 then
for _, film in ipairs(found) do
@@ -218,7 +234,7 @@ dbmaint.scan_button = dt.new_widget("button"){
end
end
else
log.log_msg(log.debug, "checking path " .. dt.collection[1].path .. " for missing files")
log.msg(log.debug, "checking path " .. dt.collection[1].path .. " for missing files")
found = scan_images(dt.collection[1].film)
if #found > 0 then
for _, image in ipairs(found) do
@@ -227,10 +243,14 @@ dbmaint.scan_button = dt.new_widget("button"){
end
end
if #found > 0 then
log.msg(log.debug, "found " .. #found .. " missing items")
dbmaint.list_widget.text = found_text
dbmaint.found = found
dbmaint.remove_button.sensitive = true
else
log.msg(log.debug, "no missing items found")
end
restore_log_level(old_log_level)
end,
reset_callback = function(this)
dbmaint.found = nil
@@ -238,11 +258,11 @@ dbmaint.scan_button = dt.new_widget("button"){
}
dbmaint.remove_button = dt.new_widget("button"){
label = "remove",
tooltip = "remove missing film rolls/images",
label = _("remove"),
tooltip = _("remove missing film rolls/images"),
sensitive = false,
clicked_callback = function(this)
if dbmaint.chooser.value == "film rolls" then
if dbmaint.chooser.selected == 1 then -- film rolls
remove_missing_film_rolls(dbmaint.found)
else
remove_missing_images(dbmaint.found)
@@ -258,7 +278,7 @@ dbmaint.remove_button = dt.new_widget("button"){
dbmaint.main_widget = dt.new_widget("box"){
orientation = "vertical",
dt.new_widget("section_label"){label = "missing items"},
dt.new_widget("section_label"){label = _("missing items")},
dbmaint.list_widget,
dt.new_widget("label"){label = ""},
dbmaint.chooser,

View File

@@ -70,8 +70,6 @@ du.check_min_api_version("7.0.0", "enfuseAdvanced")
-- Tell gettext where to find the .mo file translating messages for a particular domain
local gettext = dt.gettext.gettext
dt.gettext.bindtextdomain("enfuseAdvanced", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -81,7 +79,7 @@ end
local script_data = {}
script_data.metadata = {
name = "enfuseAdvanced",
name = _("enfuse advanced"),
purpose = _("focus stack or exposure blend images"),
author = "Kevin Ertel",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/enfuseAdvanced"
@@ -290,7 +288,13 @@ local function GetArgsFromPreference(prog_table, prefix) --for each arg in a pro
local temp = dt.preferences.read(mod, prefix..argument, arg_data.style)
if arg_data.style == 'bool' and temp then
prog_table.arg_string = prog_table.arg_string..arg_data.text..' '
elseif arg_data.style == 'integer' or arg_data.style == 'string' then
elseif arg_data.style == 'integer' then
prog_table.arg_string = prog_table.arg_string..arg_data.text..temp..' '
elseif arg_data.style == 'string' then
if dt.configuration.running_os == "windows" then
--escape % with %% as % means env var on windows and call will fail otherwise
temp = string.gsub(temp, "%%", "%%%%")
end
prog_table.arg_string = prog_table.arg_string..arg_data.text..temp..' '
elseif arg_data.style == 'float' then
temp = string.sub(tostring(temp),1,3)
@@ -347,7 +351,7 @@ local function UpdateENFargs(image_table, prefix) --updates the Enfuse arguments
end
ENF.arg_string = ENF.arg_string..' --depth='..GUI.Target.depth.value..' '
if GUI.Target.format.value == 'tif' then ENF.arg_string = ENF.arg_string..'--compression='..GUI.Target.compression_level_tif.value..' '
elseif GUI.Target.format.value == 'jpg' then ENF.arg_string = ENF.arg_string..'--compression='..GUI.Target.compression_level_jpg.value..' '
elseif GUI.Target.format.value == 'jpg' then ENF.arg_string = ENF.arg_string..'--compression='..tostring(math.floor(GUI.Target.compression_level_jpg.value))..' '
end
if not GUI.Target.source_location.value then out_path = GUI.Target.output_directory.value end
out_name = smallest_name..'-'..largest_id
@@ -445,7 +449,7 @@ local function main(storage, image_table, extra_data)
dt.print(_('too few images selected, please select at least 2 images'))
return
elseif extra_data[1] == 2 then
dt.print(_('installation error, please verify binary paths are proper'))
dt.print(_('installation error, please verify binary paths are correct'))
return
end
local images_to_remove = ''
@@ -494,6 +498,7 @@ local function main(storage, image_table, extra_data)
remove_temp_files(images_to_remove)
dt.print_error(ENF.name..' failed')
dt.print(string.format(_("%s failed"), ENF.name))
job.valid = false
return
end
@@ -956,7 +961,7 @@ if temp == '' then temp = nil end
GUI.Target.add_tags = dt.new_widget('entry'){
tooltip = _('additional tags to be added on import, seperate with commas, all spaces will be removed'),
text = temp,
placeholder = _('enter tags, seperated by commas'),
placeholder = _('enter tags, separated by commas'),
editable = true
}
temp = dt.preferences.read(mod, 'active_current_preset_ind', 'integer')
@@ -1023,7 +1028,7 @@ GUI.Presets.variants_type = dt.new_widget('combobox'){
GUI.Presets.variants_type.sensitive = GUI.Presets.variants.value
temp = df.get_executable_path_preference(AIS.name)
GUI.exes.align_image_stack = dt.new_widget('file_chooser_button'){
title = 'AIS binary path',
title = 'align_image_stack ' .. _('binary path'),
value = temp,
tooltip = temp,
is_directory = false,
@@ -1031,7 +1036,7 @@ GUI.exes.align_image_stack = dt.new_widget('file_chooser_button'){
}
temp = df.get_executable_path_preference(ENF.name)
GUI.exes.enfuse = dt.new_widget('file_chooser_button'){
title = 'enfuse binary path',
title = 'enfuse ' .. _('binary path'),
value = temp,
tooltip = temp,
is_directory = false,
@@ -1039,7 +1044,7 @@ GUI.exes.enfuse = dt.new_widget('file_chooser_button'){
}
temp = df.get_executable_path_preference(EXF.name)
GUI.exes.exiftool = dt.new_widget('file_chooser_button'){
title = 'Exiftool binary path',
title = 'exiftool ' .. _('binary path'),
value = temp,
tooltip = temp,
is_directory = false,
@@ -1167,4 +1172,4 @@ end
script_data.destroy = destroy
return script_data
return script_data

View File

@@ -35,8 +35,6 @@ du.check_min_api_version("7.0.0", "exportLUT")
local gettext = dt.gettext.gettext
dt.gettext.bindtextdomain("exportLUT", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -46,7 +44,7 @@ end
local script_data = {}
script_data.metadata = {
name = "exportLUT",
name = _("export LUT"),
purpose = _("export a style as a LUT"),
author = "Noah Clarke",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/exportLUT"
@@ -71,13 +69,13 @@ local mkdir_command = 'mkdir -p '
if dt.configuration.running_os == 'windows' then mkdir_command = 'mkdir ' end
local file_chooser_button = dt.new_widget("file_chooser_button"){
title = _("identity_file_chooser"),
title = _("choose the identity file"),
value = "",
is_directory = false
}
local export_chooser_button = dt.new_widget("file_chooser_button"){
title = _("export_location_chooser"),
title = _("choose the export location"),
value = "",
is_directory = true
}

View File

@@ -78,8 +78,6 @@ du.check_min_api_version("7.0.0", MODULE_NAME)
-- translation
local gettext = dt.gettext.gettext
dt.gettext.bindtextdomain("ext_editor", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -89,7 +87,7 @@ end
local script_data = {}
script_data.metadata = {
name = "ext_editor",
name = _("external editors"),
purpose = _("edit images with external editors"),
author = "Marco Carrarini, marco.carrarini@gmail.com",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/ext_editor"

View File

@@ -54,8 +54,6 @@ local OUTPUT = dt.configuration.tmp_dir .. PS .. "facerecognition.txt"
du.check_min_api_version("7.0.0", MODULE)
dt.gettext.bindtextdomain("face_recognition", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -65,7 +63,7 @@ end
local script_data = {}
script_data.metadata = {
name = "face_recognition",
name = _("face recognition"),
purpose = _("use facial recognition to tag images"),
author = "Sebastian Witt",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/face_recognition"

View File

@@ -65,8 +65,6 @@ local gettext = dt.gettext.gettext
du.check_min_api_version("7.0.0", "fujifilm_dynamic_range")
dt.gettext.bindtextdomain("fujifilm_dynamic_range", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -76,7 +74,7 @@ end
local script_data = {}
script_data.metadata = {
name = "fujifilm_dynamic_range",
name = _("fujifilm dynamic range"),
purpose = _("compensate for Fujifilm raw files made using \"dynamic range\""),
author = "Dan Torop <dant@pnym.net>",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/fujifilm_dynamic_range"

View File

@@ -31,8 +31,6 @@ local gettext = dt.gettext.gettext
du.check_min_api_version("7.0.0", "fujifilm_ratings")
dt.gettext.bindtextdomain("fujifilm_ratings", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -42,7 +40,7 @@ end
local script_data = {}
script_data.metadata = {
name = "fujifilm_ratings",
name = _("fujifilm ratings"),
purpose = _("import Fujifilm in-camera ratings"),
author = "Ben Mendis <ben.mendis@gmail.com>",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/fujifilm_ratings"
@@ -58,7 +56,7 @@ local function detect_rating(event, image)
return
end
if not df.check_if_bin_exists("exiftool") then
dt.print_error(_("exiftool not found"))
dt.print_error("exiftool not found")
return
end
local RAF_filename = df.sanitize_filename(tostring(image))

View File

@@ -40,8 +40,6 @@ local gettext = dt.gettext.gettext
du.check_min_api_version("7.0.0", "geoJSON_export")
dt.gettext.bindtextdomain("geoJSON_export", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -51,7 +49,7 @@ end
local script_data = {}
script_data.metadata = {
name = "geoJSON_export",
name = _("geoJSON export"),
purpose = _("export a geoJSON file from geo data"),
author = "Tobias Jakobs",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/geoJSON_export"
@@ -92,19 +90,19 @@ end
local function create_geoJSON_file(storage, image_table, extra_data)
if not df.check_if_bin_exists("mkdir") then
dt.print_error(_("mkdir not found"))
dt.print_error("mkdir not found")
return
end
if not df.check_if_bin_exists("convert") then
dt.print_error(_("convert not found"))
dt.print_error("convert not found")
return
end
if not df.check_if_bin_exists("xdg-open") then
dt.print_error(_("xdg-open not found"))
dt.print_error("xdg-open not found")
return
end
if not df.check_if_bin_exists("xdg-user-dir") then
dt.print_error(_("xdg-user-dir not found"))
dt.print_error("xdg-user-dir not found")
return
end
@@ -295,7 +293,7 @@ local function create_geoJSON_file(storage, image_table, extra_data)
file:write(geoJSON_file)
file:close()
dt.print("geoJSON file created in "..exportDirectory)
dt.print(string.format(_("%s file created in %s"), "geoJSON", exportDirectory))
-- Open the file with the standard programm
if ( dt.preferences.read("geoJSON_export","OpengeoJSONFile","bool") == true ) then
@@ -315,19 +313,19 @@ end
dt.preferences.register("geoJSON_export",
"CreateMapBoxHTMLFile",
"bool",
_("geoJSON export: Create an additional HTML file"),
"geoJSON export: ".._("Create an additional HTML file"),
_("creates an HTML file that loads the geoJSON file. (needs a MapBox key"),
false )
dt.preferences.register("geoJSON_export",
"mapBoxKey",
"string",
_("geoJSON export: MapBox key"),
"geoJSON export: MapBox " .. _("key"),
"https://www.mapbox.com/studio/account/tokens",
'' )
dt.preferences.register("geoJSON_export",
"OpengeoJSONFile",
"bool",
_("geoJSON export: open geoJSON file after export"),
"geoJSON export: " .. _("open geoJSON file after export"),
_("opens the geoJSON file after the export with the standard program for geoJSON files"),
false )

View File

@@ -1,6 +1,7 @@
--[[
This file is part of darktable,
copyright (c) 2016 Tobias Jakobs
copyright (c) 2025 Balázs Dura-Kovács
darktable is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@@ -33,8 +34,6 @@ local gettext = dt.gettext.gettext
du.check_min_api_version("7.0.0", "geoToolbox")
dt.gettext.bindtextdomain("geoToolbox", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -44,7 +43,7 @@ end
local script_data = {}
script_data.metadata = {
name = "geoToolbox",
name = _("geo toolbox"),
purpose = _("geodata tools"),
author = "Tobias Jakobs",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/geoToolbox"
@@ -65,21 +64,33 @@ gT.event_registered = false
local labelDistance = dt.new_widget("label")
labelDistance.label = _("distance:")
local label_copy_gps_lat = dt.new_widget("check_button")
local checkbox_copy_gps_lat = dt.new_widget("check_button")
{
label = _("latitude:"),
value = true
}
local label_copy_gps_lon = dt.new_widget("check_button")
local entry_gps_lat = dt.new_widget("entry")
{
tooltip = _("latitude (editable)"),
placeholder = _("latitude")
}
local checkbox_copy_gps_lon = dt.new_widget("check_button")
{
label = _("longitude:"),
value = true
}
local label_copy_gps_ele = dt.new_widget("check_button")
local entry_gps_lon = dt.new_widget("entry")
{
tooltip = _("longitude (editable)"),
placeholder = _("longitude")
}
local checkbox_copy_gps_ele = dt.new_widget("check_button")
{
label = _("elevation:"),
value = true
}
local entry_gps_ele = dt.new_widget("entry")
{
tooltip = _("elevation (editable)"),
placeholder = _("elevation")
}
-- </GUI>
local function select_with_gps()
@@ -169,7 +180,7 @@ local function get_first_coordinate()
first_elevation = ''
first_image_date = 0
for _,image in ipairs(sel_images) do
for jj,image in ipairs(sel_images) do
if not image then
first_have_data = false
else
@@ -208,7 +219,7 @@ local function get_second_coordinate()
second_elevation = ''
second_image_date = 0
for _,image in ipairs(sel_images) do
for jj,image in ipairs(sel_images) do
if not image then
second_have_data = false
else
@@ -244,7 +255,7 @@ local calc_in_between_slider = dt.new_widget("slider")
--ToDo: this needs more love
local function calc_in_between()
local sel_images = dt.gui.action_images
for _,image in ipairs(sel_images) do
for jj,image in ipairs(sel_images) do
if image then
image_date = make_time_stamp(image.exif_datetime_taken)
if (first_have_data and second_have_data) then
@@ -291,25 +302,25 @@ local function copy_gps()
copy_gps_longitude = ''
copy_gps_elevation = ''
for _,image in ipairs(sel_images) do
for jj,image in ipairs(sel_images) do
if not image then
copy_gps_have_data = false
else
copy_gps_have_data = true
if (image.latitude and label_copy_gps_lat.value) then
if (image.latitude and checkbox_copy_gps_lat.value) then
copy_gps_latitude = image.latitude
end
if (image.longitude and label_copy_gps_lon.value) then
if (image.longitude and checkbox_copy_gps_lon.value) then
copy_gps_longitude = image.longitude
end
if (image.elevation and label_copy_gps_ele.value) then
if (image.elevation and checkbox_copy_gps_ele.value) then
copy_gps_elevation = image.elevation
end
end
label_copy_gps_lat.label = string.format(_("latitude: "), copy_gps_latitude)
label_copy_gps_lon.label = string.format(_("longitude: "),copy_gps_longitude)
label_copy_gps_ele.label = string.format(_("elevation: "), copy_gps_elevation)
entry_gps_lat.text = copy_gps_latitude
entry_gps_lon.text = copy_gps_longitude
entry_gps_ele.text = copy_gps_elevation
return
end
@@ -318,15 +329,15 @@ end
local function paste_gps(image)
local sel_images = dt.gui.action_images
for _,image in ipairs(sel_images) do
if (label_copy_gps_lat.value) then
image.latitude = copy_gps_latitude
for jj,image in ipairs(sel_images) do
if (checkbox_copy_gps_lat.value) then
image.latitude = entry_gps_lat.text
end
if (label_copy_gps_lon.value) then
image.longitude = copy_gps_longitude
if (checkbox_copy_gps_lon.value) then
image.longitude = entry_gps_lon.text
end
if (label_copy_gps_ele.value) then
image.elevation = copy_gps_elevation
if (checkbox_copy_gps_ele.value) then
image.elevation = entry_gps_ele.text
end
end
end
@@ -345,7 +356,7 @@ local function open_location_in_gnome_maps()
local i = 0;
-- Use the first image with geo information
for _,image in ipairs(sel_images) do
for jj,image in ipairs(sel_images) do
if ((image.longitude and image.latitude) and
(image.longitude ~= 0 and image.latitude ~= 90) -- Sometimes the north-pole but most likely just wrong data
) then
@@ -372,12 +383,12 @@ end
local function reverse_geocode()
if not df.check_if_bin_exists("curl") then
dt.print_error(_("curl not found"))
dt.print_error("curl not found")
return
end
if not df.check_if_bin_exists("jq") then
dt.print_error(_("jq not found"))
dt.print_error("jq not found")
return
end
@@ -388,7 +399,7 @@ local function reverse_geocode()
local i = 0;
-- Use the first image with geo information
for _,image in ipairs(sel_images) do
for jj,image in ipairs(sel_images) do
if ((image.longitude and image.latitude) and
(image.longitude ~= 0 and image.latitude ~= 90) -- Sometimes the north-pole but most likely just wrong data
) then
@@ -463,7 +474,7 @@ local function calc_distance()
local sel_images = dt.gui.selection()
for _,image in ipairs(sel_images) do
for jj,image in ipairs(sel_images) do
if ((image.longitude and image.latitude) and
(image.longitude ~= 0 and image.latitude ~= 90) -- Sometimes the north-pole but most likely just wrong data
) then
@@ -547,7 +558,7 @@ local function altitude_profile()
local elevationAdd = 0;
local sel_images = dt.gui.action_images
for _,image in ipairs(sel_images) do
for jj,image in ipairs(sel_images) do
if ((not isnan(image.longitude) and not isnan(image.latitude) and not isnan(image.elevation) and image.elevation) and
(image.longitude ~= 0 and image.latitude ~= 90) -- Sometimes the north-pole but most likely just wrong data
) then
@@ -597,7 +608,7 @@ local function install_module()
if not gT.module_installed then
dt.register_lib(
"geoToolbox", -- Module name
"geo toolbox", -- name
_("geo toolbox"), -- name
true, -- expandable
false, -- resetable
{[dt.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 100}}, -- containers
@@ -665,13 +676,25 @@ gT.widget = dt.new_widget("box")
tooltip = _("copy GPS data"),
clicked_callback = copy_gps
},
label_copy_gps_lat,
label_copy_gps_lon,
label_copy_gps_ele,
dt.new_widget("box"){
orientation = "horizontal",
checkbox_copy_gps_lat,
entry_gps_lat
},
dt.new_widget("box"){
orientation = "horizontal",
checkbox_copy_gps_lon,
entry_gps_lon
},
dt.new_widget("box"){
orientation = "horizontal",
checkbox_copy_gps_ele,
entry_gps_ele
},
dt.new_widget("button")
{
label = _("paste GPS data"),
tooltip = _("paste GPS data"),
label = _("apply GPS data to image"),
tooltip = _("apply GPS data to image"),
clicked_callback = paste_gps
},
separator2,--------------------------------------------------------

View File

@@ -74,8 +74,6 @@ local gimp_widget = nil
du.check_min_api_version("7.0.0", "gimp")
dt.gettext.bindtextdomain("gimp", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -85,7 +83,7 @@ end
local script_data = {}
script_data.metadata = {
name = "gimp",
name = _("edit with GIMP"),
purpose = _("export and edit with GIMP"),
author = "Bill Ferguson <wpferguson@gmail.com>",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/gimp"

View File

@@ -25,14 +25,12 @@ For each source folder, a separate <trk> is generated in the gpx file.
local dt = require "darktable"
local df = require "lib/dtutils.file"
local dl = require "lib/dtutils"
local gettext = dt.gettext
local gettext = dt.gettext.gettext
dl.check_min_api_version("7.0.0", "gpx_export")
gettext.bindtextdomain("gpx_export", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext.dgettext("gpx_export", msgid)
return gettext(msgid)
end
-- return data structure for script_manager
@@ -40,7 +38,7 @@ end
local script_data = {}
script_data.metadata = {
name = "gpx_export",
name = _("gpx export"),
purpose = _("export gpx information to a file"),
author = "Jannis_V",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/gpx_export"
@@ -150,7 +148,7 @@ local function install_module()
if not gpx.module_installed then
dt.register_lib(
"gpx_exporter",
"gpx export",
_("gpx export"),
true, -- expandable
true, -- resetable
{[dt.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 100}}, -- containers

View File

@@ -36,8 +36,6 @@ local gettext = dt.gettext.gettext
du.check_min_api_version("2.0.0", "harmonic_armature_guide")
dt.gettext.bindtextdomain("harmonic_armature_guide", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -45,7 +43,7 @@ end
local script_data = {}
script_data.metadata = {
name = "harmonic_armature_guide",
name = _("harmonic armature guide"),
purpose = _("harmonic artmature guide"),
author = "Hubert Kowalski",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/harmonic_armature_guide"

View File

@@ -64,8 +64,6 @@ du.check_min_api_version("7.0.0", MODULE)
local gettext = dt.gettext.gettext
dt.gettext.bindtextdomain("hif_group_leader", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -77,7 +75,7 @@ end
local script_data = {}
script_data.metadata = {
name = "hif_group_leader",
name = _("HIF group leader"),
purpose = _("make hif image group leader"),
author = "Bill Ferguson <wpferguson@gmail.com>",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/hif_group_leader"

View File

@@ -56,8 +56,6 @@ local PQ = dt.configuration.running_os == "windows" and '"' or "'"
-- works with darktable API version from 5.0.0 on
du.check_min_api_version("7.0.0", "hugin")
dt.gettext.bindtextdomain("hugin", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -67,7 +65,7 @@ end
local script_data = {}
script_data.metadata = {
name = "hugin",
name = _("hugin"),
purpose = _("stitch images into a panorama"),
author = "Wolfgang Goetz",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/hugin"
@@ -85,7 +83,7 @@ end
local function show_status(storage, image, format, filename,
number, total, high_quality, extra_data)
dt.print("exporting to hugin: "..tostring(number).."/"..tostring(total))
dt.print(string.format(_("exporting to hugin: %d / %d"), number, total))
end
local function create_panorama(storage, image_table, extra_data) --finalize
@@ -146,7 +144,7 @@ local function create_panorama(storage, image_table, extra_data) --finalize
end
if first_file == nil then
dt.print("no file selected")
dt.print(_("no file selected"))
return
end

View File

@@ -73,8 +73,6 @@ local PS = dt.configuration.running_os == "windows" and "\\" or "/"
-- works with LUA API version 5.0.0
du.check_min_api_version("7.0.0", "image_stack")
dt.gettext.bindtextdomain("image_stack", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -84,7 +82,7 @@ end
local script_data = {}
script_data.metadata = {
name = "image_stack",
name = _("image stack"),
purpose = _("process a stack of images"),
author = "Bill Ferguson <wpferguson@gmail.com>",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/image_stack"
@@ -141,19 +139,19 @@ local chkbtn_will_align = dt.new_widget("check_button"){
local chkbtn_radial_distortion = dt.new_widget("check_button"){
label = _('optimize radial distortion for all images'),
value = dt.preferences.read("align_image_stack", "def_radial_distortion", "bool"),
tooltip = _('optimize radial distortion for all images, \nexcept for first'),
tooltip = _('optimize radial distortion for all images, \nexcept the first'),
}
local chkbtn_optimize_field = dt.new_widget("check_button"){
label = _('optimize field of view for all images'),
value = dt.preferences.read("align_image_stack", "def_optimize_field", "bool"),
tooltip =_('optimize field of view for all images, except for first. \nUseful for aligning focus stacks (DFF) with slightly \ndifferent magnification.'),
tooltip =_('optimize field of view for all images, except the first. \nUseful for aligning focus stacks (DFF) with slightly \ndifferent magnification.'),
}
local chkbtn_optimize_image_center = dt.new_widget("check_button"){
label = _('optimize image center shift for all images'),
value = dt.preferences.read("align_image_stack", "def_optimize_image_center", "bool"),
tooltip =_('optimize image center shift for all images, \nexcept for first.'),
tooltip =_('optimize image center shift for all images, \nexcept the first.'),
}
local chkbtn_auto_crop = dt.new_widget("check_button"){
@@ -506,7 +504,7 @@ local function image_stack(storage, image_table, extra_data)
return
end
job = dt.gui.create_job("image stack", true, stop_job)
job = dt.gui.create_job(_("image stack"), true, stop_job)
job.percent = job.percent + percent_step
-- align images if requested

View File

@@ -116,8 +116,6 @@ img_time.event_registered = false
du.check_min_api_version("7.0.0", "image_time")
dt.gettext.bindtextdomain("image_time", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -127,7 +125,7 @@ end
local script_data = {}
script_data.metadata = {
name = "image_time",
name = _("image time"),
purpose = _("synchronize image time for images shot with different cameras or adjust or set image time"),
author = "Bill Ferguson <wpferguson@gmail.com>",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/image_time"
@@ -156,12 +154,12 @@ local function systime2exiftime(systime)
end
local function vars2exiftime(year, month, day, hour, min, sec)
local y = tonumber(year) and string.format("%4d", year) or " "
local mo = tonumber(month) and string.format("%02d", month) or " "
local d = tonumber(day) and string.format("%02d", day) or " "
local h = tonumber(hour) and string.format("%02d", hour) or " "
local m = tonumber(min) and string.format("%02d", min) or " "
local s = tonumber(sec) and string.format("%02d", sec) or " "
local y = tonumber(year) and string.format("%4d", year) or "0000"
local mo = tonumber(month) and string.format("%02d", month) or "00"
local d = tonumber(day) and string.format("%02d", day) or "00"
local h = tonumber(hour) and string.format("%02d", hour) or "00"
local m = tonumber(min) and string.format("%02d", min) or "00"
local s = tonumber(sec) and string.format("%02d", sec) or "00"
return(y .. ":" .. mo .. ":" .. d .. " " .. h .. ":" .. m .. ":" .. s)
end
@@ -195,7 +193,7 @@ end
local function synchronize_time(images)
local sign = 1
if img_time.sdir.value == "subtract" then
if img_time.sdir.value == _("subtract") then
sign = -1
end
synchronize_times(images, tonumber(img_time.diff_entry.text) * sign)
@@ -213,7 +211,6 @@ local function year_month2months(year, month)
end
local function months2year_month(months)
dt.print_log("months is " .. months)
local year = math.floor(months / 12)
local month = months - (year * 12)
@@ -226,7 +223,7 @@ local function get_image_taken_time(image)
local exiv2 = df.check_if_bin_exists("exiv2")
if exiv2 then
p = io.popen(exiv2 .. " -K Exif.Image.DateTime " .. image.path .. PS .. image.filename)
p = io.popen(exiv2 .. " -K Exif.Image.DateTime " .. df.sanitize_filename(image.path .. PS .. image.filename))
if p then
for line in p:lines() do
if string.match(line, "Exif.Image.DateTime") then
@@ -265,7 +262,7 @@ end
local function _get_nix_image_file_creation_time(image)
local datetime = nil
local p = io.popen("ls -lL --time-style=full-iso " .. image.path .. PS .. image.filename)
local p = io.popen("ls -lL --time-style=full-iso " .. df.sanitize_filename(image.path .. PS .. image.filename))
if p then
for line in p:lines() do
if string.match(line, ds.sanitize_lua(image.filename)) then
@@ -511,7 +508,7 @@ img_time.stack = dt.new_widget("stack"){
dt.new_widget("box"){
orientation = "vertical",
dt.new_widget("label"){label = _("set time")},
dt.new_widget("section_label"){label = _("date: ")},
dt.new_widget("section_label"){label = _("date:")},
img_time.sdy,
img_time.smo,
img_time.syr,

View File

@@ -64,8 +64,6 @@ du.check_min_api_version("7.0.0", MODULE)
local gettext = dt.gettext.gettext
dt.gettext.bindtextdomain("jpg_group_leader", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -77,7 +75,7 @@ end
local script_data = {}
script_data.metadata = {
name = "jpg_group_leader",
name = _("JPG group leader"),
purpose = _("make jpg image group leader"),
author = "Bill Ferguson <wpferguson@gmail.com>",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/jpg_group_leader"

View File

@@ -45,8 +45,6 @@ local PS = dt.configuration.running_os == "windows" and "\\" or "/"
du.check_min_api_version("7.0.0", "kml_export")
dt.gettext.bindtextdomain("kml_export", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -56,7 +54,7 @@ end
local script_data = {}
script_data.metadata = {
name = "kml_export",
name = _("kml export"),
purpose = _("export KML/KMZ data to a file"),
author = "Tobias Jakobs",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/kml_export"
@@ -95,12 +93,12 @@ local function create_kml_file(storage, image_table, extra_data)
end
if not df.check_if_bin_exists(magickPath) then
dt.print_error(_("magick not found"))
dt.print_error("magick not found")
return
end
if dt.configuration.running_os == "linux" then
if not df.check_if_bin_exists("xdg-user-dir") then
dt.print_error(_("xdg-user-dir not found"))
dt.print_error("xdg-user-dir not found")
return
end
end
@@ -110,7 +108,7 @@ local function create_kml_file(storage, image_table, extra_data)
if ( dt.preferences.read("kml_export","CreateKMZ","bool") == true
and dt.configuration.running_os == "linux") then
if not df.check_if_bin_exists("zip") then
dt.print_error(_("zip not found"))
dt.print_error("zip not found")
return
end
exportDirectory = dt.configuration.tmp_dir

View File

@@ -41,8 +41,6 @@ local gettext = dt.gettext.gettext
du.check_min_api_version("2.0.0", "passport_guide")
dt.gettext.bindtextdomain("passport_guide", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -50,7 +48,7 @@ end
local script_data = {}
script_data.metadata = {
name = "passport_guide",
name = _("passport guide"),
purpose = _("guides for cropping passport photos"),
author = "Kåre Hampf",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/passport_guide"

View File

@@ -40,21 +40,19 @@ USAGE
local dt = require "darktable"
local du = require "lib/dtutils"
local gettext = dt.gettext
local gettext = dt.gettext.gettext
du.check_min_api_version("2.0.0", "passport_guide_germany")
-- Tell gettext where to find the .mo file translating messages for a particular domain
dt.gettext.bindtextdomain("passport_guide_germany",dt.configuration.config_dir.."/lua/locale/")
local function _(msgid)
return gettext.dgettext("passport_guide_germany", msgid)
return gettext(msgid)
end
local script_data = {}
script_data.metadata = {
name = "passport_guide_germany",
name = _("passport guide Germany"),
purpose = _("guides for cropping passport and ID card (\"Personalausweis\") photos based on the \"Passbild-Schablone\" from the German Federal Ministry of the Interior and Community"),
author = "menschmachine",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/passport_guide_germany"

View File

@@ -44,8 +44,6 @@ local df = require "lib/dtutils.file"
local gettext = dt.gettext.gettext
dt.gettext.bindtextdomain("pdf_slideshow", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -62,7 +60,7 @@ du.check_min_api_version("7.0.0", "pdf_slideshow")
local script_data = {}
script_data.metadata = {
name = "pdf_slideshow",
name = _("PDF slideshow"),
purpose = _("generates a PDF slideshow (via Latex) containing all selected images one per slide"),
author = "Pascal Obry",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/pdf_slideshow"

View File

@@ -45,7 +45,6 @@ local MODULE_NAME = "photils"
du.check_min_api_version("7.0.0", MODULE_NAME)
local gettext = dt.gettext.gettext
dt.gettext.bindtextdomain("photils", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
@@ -56,7 +55,7 @@ end
local script_data = {}
script_data.metadata = {
name = "photils",
name = _("photils"),
purpose = _("suggest tags based on image classification"),
author = "Tobias Scheck",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/photils"
@@ -265,10 +264,6 @@ function PHOTILS.get_tags(image, with_export)
local out_arg = df.sanitize_filename(tmp_file)
local executable = photils_installed
if dt.configuration.running_os == "macos" then
executable = executable .. "/Contents/MacOS/photils-cli"
end
if with_export then
dt.print_log("use export to for prediction")
local export_file = df.create_tmp_file()
@@ -339,7 +334,7 @@ function PHOTILS.on_tags_clicked()
end
if #PHOTILS.tags == 0 then
local msg = string.format(_("no tags where found"), MODULE_NAME)
local msg = string.format(_("no tags were found"), MODULE_NAME)
GUI.warning_label.label = msg
GUI.stack.active = GUI.error_view
return
@@ -394,7 +389,7 @@ end
local function install_module()
if not PHOTILS.module_installed then
dt.register_lib(MODULE_NAME,
"photils autotagger",
_("photils auto-tagger"),
true,
true,
PHOTILS.plugin_display_views,

View File

@@ -51,8 +51,6 @@ du.check_min_api_version("7.0.0", "quicktag")
local gettext = dt.gettext.gettext
dt.gettext.bindtextdomain("quicktag", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -62,7 +60,7 @@ end
local script_data = {}
script_data.metadata = {
name = "quicktag",
name = _("quick tag"),
purpose = _("use buttons to quickly apply tags assigned to them"),
author = "Christian Kanzian",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/quicktag"
@@ -199,7 +197,7 @@ local function install_module()
if not qt.module_installed then
dt.register_lib(
"quicktag", -- Module name
"quicktag", -- name
_("quick tag"), -- name
true, -- expandable
false, -- resetable
{[dt.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 490}},
@@ -306,7 +304,7 @@ end
for i=1,qnr do
dt.register_event("quicktag " .. tostring(i), "shortcut",
function(event, shortcut) tagattach(tostring(quicktag_table[i])) end,
string.format(_("quicktag %i"),i))
string.format(_("quick tag %i"),i))
end
script_data.destroy = destroy

View File

@@ -46,8 +46,6 @@ du.check_min_api_version("7.0.0", "rate_group")
local gettext = dt.gettext.gettext
dt.gettext.bindtextdomain("rate_group", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -57,7 +55,7 @@ end
local script_data = {}
script_data.metadata = {
name = "rate_group",
name = _("rate group"),
purpose = _("rate all images in a group"),
author = "Dom H (dom@hxy.io)",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/rate_group"
@@ -93,8 +91,6 @@ local function destroy()
dt.destroy_event("rg5", "shortcut")
end
local rate_group = _("rate group")
dt.register_event("rg_reject", "shortcut",
function(event, shortcut)
apply_rating(-1)
@@ -103,32 +99,33 @@ end, _("reject group"))
dt.register_event("rg0", "shortcut",
function(event, shortcut)
apply_rating(0)
end, rate group .. " 0")
end, string.format(_("rate group %d"), 0)
)
dt.register_event("rg1", "shortcut",
function(event, shortcut)
apply_rating(1)
end, rate group .. " 1")
end, string.format(_("rate group %d"), 1))
dt.register_event("rg2", "shortcut",
function(event, shortcut)
apply_rating(2)
end, rate group .. " 2")
end, string.format(_("rate group %d"), 2))
dt.register_event("rg3", "shortcut",
function(event, shortcut)
apply_rating(3)
end, rate group .. " 3")
end, string.format(_("rate group %d"), 3))
dt.register_event("rg4", "shortcut",
function(event, shortcut)
apply_rating(4)
end, rate group .. " 4")
end, string.format(_("rate group %d"), 4))
dt.register_event("rg5", "shortcut",
function(event, shortcut)
apply_rating(5)
end, rate group .. " 5")
end, string.format(_("rate group %d"), 5))
script_data.destroy = destroy

View File

@@ -38,8 +38,6 @@ du.deprecated("contrib/rename-tags.lua","darktable release 4.0")
local gettext = darktable.gettext.gettext
darktable.gettext.bindtextdomain("rename-tags", darktable.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -49,7 +47,7 @@ end
local script_data = {}
script_data.metadata = {
name = "rename-tags",
name = _("rename tags"),
purpose = _("rename an existing tag"),
author = "Sebastian Witt (se.witt@gmx.net)",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/rename-tags"

View File

@@ -43,13 +43,12 @@
local dt = require "darktable"
local du = require "lib/dtutils"
local df = require "lib/dtutils.file"
local ds = require "lib/dtutils.string"
du.check_min_api_version("7.0.0", "rename_images")
local gettext = dt.gettext.gettext
dt.gettext.bindtextdomain("rename_images", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -57,12 +56,6 @@ end
-- namespace variable
local rename = {
presets = {},
substitutes = {},
placeholders = {"ROLL_NAME","FILE_FOLDER","FILE_NAME","FILE_EXTENSION","ID","VERSION","SEQUENCE","YEAR","MONTH","DAY",
"HOUR","MINUTE","SECOND","EXIF_YEAR","EXIF_MONTH","EXIF_DAY","EXIF_HOUR","EXIF_MINUTE","EXIF_SECOND",
"STARS","LABELS","MAKER","MODEL","TITLE","CREATOR","PUBLISHER","RIGHTS","USERNAME","PICTURES_FOLDER",
"HOME","DESKTOP","EXIF_ISO","EXIF_EXPOSURE","EXIF_EXPOSURE_BIAS","EXIF_APERTURE","EXIF_FOCUS_DISTANCE",
"EXIF_FOCAL_LENGTH","LONGITUDE","LATITUDE","ELEVATION","LENS","DESCRIPTION","EXIF_CROP"},
widgets = {},
}
rename.module_installed = false
@@ -72,7 +65,7 @@ rename.event_registered = false
local script_data = {}
script_data.metadata = {
name = "rename_images",
name = _("rename images"),
purpose = _("rename an image file or files"),
author = "Bill Ferguson <wpferguson@gmail.com>",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/rename_images"
@@ -99,83 +92,6 @@ local DESKTOP = HOME .. PS .. "Desktop"
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
local function build_substitution_list(image, sequence, datetime, username, pic_folder, home, desktop)
-- build the argument substitution list from each image
-- local datetime = os.date("*t")
local colorlabels = {}
if image.red then table.insert(colorlabels, "red") end
if image.yellow then table.insert(colorlabels, "yellow") end
if image.green then table.insert(colorlabels, "green") end
if image.blue then table.insert(colorlabels, "blue") end
if image.purple then table.insert(colorlabels, "purple") end
local labels = #colorlabels == 1 and colorlabels[1] or du.join(colorlabels, ",")
local eyear,emon,eday,ehour,emin,esec = string.match(image.exif_datetime_taken, "(%d-):(%d-):(%d-) (%d-):(%d-):(%d-)$")
local replacements = {image.film,
image.path,
df.get_filename(image.filename),
string.upper(df.get_filetype(image.filename)),
image.id,image.duplicate_index,
string.format("%04d", sequence),
datetime.year,
string.format("%02d", datetime.month),
string.format("%02d", datetime.day),
string.format("%02d", datetime.hour),
string.format("%02d", datetime.min),
string.format("%02d", datetime.sec),
eyear,
emon,
eday,
ehour,
emin,
esec,
image.rating,
labels,
image.exif_maker,
image.exif_model,
image.title,
image.creator,
image.publisher,
image.rights,
username,
pic_folder,
home,
desktop,
image.exif_iso,
image.exif_exposure,
image.exif_exposure_bias,
image.exif_aperture,
image.exif_focus_distance,
image.exif_focal_length,
image.longitude,
image.latitude,
image.elevation,
image.exif_lens,
image.description,
image.exif_crop
}
for i=1,#rename.placeholders,1 do rename.substitutes[rename.placeholders[i]] = replacements[i] end
end
local function substitute_list(str)
-- replace the substitution variables in a string
for match in string.gmatch(str, "%$%(.-%)") do
local var = string.match(match, "%$%((.-)%)")
if rename.substitutes[var] then
str = string.gsub(str, "%$%("..var.."%)", rename.substitutes[var])
else
dt.print_error("unrecognized variable " .. var)
dt.print(string.format(_("unknown variable %s, aborting..."), var))
return -1
end
end
return str
end
local function clear_substitute_list()
for i=1,#rename.placeholders,1 do rename.substitutes[rename.placeholders[i]] = nil end
end
local function stop_job(job)
job.valid = false
end
@@ -225,14 +141,14 @@ local function do_rename(images)
for i, image in ipairs(images) do
if job.valid then
job.percent = i / #images
build_substitution_list(image, i, datetime, USER, PICTURES, HOME, DESKTOP)
local new_name = substitute_list(pattern)
ds.build_substitute_list(image, i, pattern, USER, PICTURES, HOME, DESKTOP)
local new_name = ds.substitute_list(pattern)
if new_name == -1 then
dt.print(_("unable to do variable substitution, exiting..."))
stop_job(job)
return
end
clear_substitute_list()
ds.clear_substitute_list()
local args = {}
local path = string.sub(df.get_path(new_name), 1, -2)
if string.len(path) == 0 then
@@ -269,60 +185,19 @@ local function do_rename(images)
end
end
local function reset_callback()
rename.widgets.pattern.text = ""
end
-- - - - - - - - - - - - - - - - - - - - - - - -
-- W I D G E T S
-- - - - - - - - - - - - - - - - - - - - - - - -
rename.widgets.pattern = dt.new_widget("entry"){
tooltip = _("$(ROLL_NAME) - film roll name\n") ..
_("$(FILE_FOLDER) - image file folder\n") ..
_("$(FILE_NAME) - image file name\n") ..
_("$(FILE_EXTENSION) - image file extension\n") ..
_("$(ID) - image id\n") ..
_("$(VERSION) - version number\n") ..
_("$(SEQUENCE) - sequence number of selection\n") ..
_("$(YEAR) - current year\n") ..
_("$(MONTH) - current month\n") ..
_("$(DAY) - current day\n") ..
_("$(HOUR) - current hour\n") ..
_("$(MINUTE) - current minute\n") ..
_("$(SECOND) - current second\n") ..
_("$(EXIF_YEAR) - EXIF year\n") ..
_("$(EXIF_MONTH) - EXIF month\n") ..
_("$(EXIF_DAY) - EXIF day\n") ..
_("$(EXIF_HOUR) - EXIF hour\n") ..
_("$(EXIF_MINUTE) - EXIF minute\n") ..
_("$(EXIF_SECOND) - EXIF seconds\n") ..
_("$(EXIF_ISO) - EXIF ISO\n") ..
_("$(EXIF_EXPOSURE) - EXIF exposure\n") ..
_("$(EXIF_EXPOSURE_BIAS) - EXIF exposure bias\n") ..
_("$(EXIF_APERTURE) - EXIF aperture\n") ..
_("$(EXIF_FOCAL_LENGTH) - EXIF focal length\n") ..
_("$(EXIF_FOCUS_DISTANCE) - EXIF focus distance\n") ..
_("$(EXIF_CROP) - EXIF crop\n") ..
_("$(LONGITUDE) - longitude\n") ..
_("$(LATITUDE) - latitude\n") ..
_("$(ELEVATION) - elevation\n") ..
_("$(STARS) - star rating\n") ..
_("$(LABELS) - color labels\n") ..
_("$(MAKER) - camera maker\n") ..
_("$(MODEL) - camera model\n") ..
_("$(LENS) - lens\n") ..
_("$(TITLE) - title from metadata\n") ..
_("$(DESCRIPTION) - description from metadata\n") ..
_("$(CREATOR) - creator from metadata\n") ..
_("$(PUBLISHER) - publisher from metadata\n") ..
_("$(RIGHTS) - rights from metadata\n") ..
_("$(USERNAME) - username\n") ..
_("$(PICTURES_FOLDER) - pictures folder\n") ..
_("$(HOME) - user's home directory\n") ..
_("$(DESKTOP) - desktop directory"),
placeholder = _("enter pattern $(FILE_FOLDER)/$(FILE_NAME)"),
text = ""
tooltip = ds.get_substitution_tooltip(),
placeholder = _("enter pattern") .. "$(FILE_FOLDER)/$(FILE_NAME)",
text = "",
reset_callback = function(self)
self.text = ""
dt.preferences.write(MODULE_NAME, "pattern", "string", self.text)
end
}
local pattern_pref = dt.preferences.read(MODULE_NAME, "pattern", "string")

View File

@@ -20,19 +20,22 @@ Enable selection of non-existing images in the the currently worked on images, e
local dt = require "darktable"
local du = require "lib/dtutils"
local df = require "lib/dtutils.file"
local ds = require "lib/dtutils.string"
local log = require "lib/dtutils.log"
-- module name
local MODULE = "select_non_existing"
local MODULE <const> = "select_non_existing"
du.check_min_api_version("9.1.0", MODULE)
-- figure out the path separator
local PS = dt.configuration.running_os == "windows" and "\\" or "/"
local PS <const> = dt.configuration.running_os == "windows" and "\\" or "/"
local DEFAULT_LOG_LEVEL <const> = log.info
log.log_level(DEFAULT_LOG_LEVEL)
local gettext = dt.gettext.gettext
dt.gettext.bindtextdomain("select_non_existing", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -41,26 +44,61 @@ local function stop_job(job)
job.valid = false
end
local function select_nonexisting_images(event, images)
local selection = {}
local function get_file_list(dir)
log.log_level(DEFAULT_LOG_LEVEL)
local file_list = {}
local cmd = nil
log.msg(log.debug, "called get_file_list with " .. dir)
local job = dt.gui.create_job(_("select non existing images"), true, stop_job)
for key,image in ipairs(images) do
if(job.valid) then
job.percent = (key - 1)/#images
local filepath = image.path..PS..image.filename
local file_exists = df.test_file(filepath, "e")
dt.print_log(filepath.." exists? => "..tostring(file_exists))
if (not file_exists) then
table.insert(selection, image)
end
else
break
if dt.configuration.running_os == "windows" then
cmd = "forfiles /P " .. ds.sanitize(dir) .. " /M * /C \"cmd /c echo @file\""
else
cmd = "cd " .. ds.sanitize(dir) ..";ls -1"
end
log.msg(log.info, "cmd is " .. cmd)
local p = io.popen(cmd)
if p then
for line in p:lines() do
if line:len() > 4 then
if not string.match(line, "xmp$") and not string.match(line, "XMP$") then
line = line:gsub("\"", "")
file_list[line] = true
end
end
stop_job(job)
return selection
end
end
p:close()
end
return file_list
end
local function select_nonexisting_images(event, images)
log.log_level(DEFAULT_LOG_LEVEL)
local selection = {}
local known_files = {}
local job = dt.gui.create_job(_("select non existing images"), true, stop_job)
for key,image in ipairs(images) do
if(job.valid) then
if not known_files[image.path] then
known_files[image.path] = get_file_list(image.path)
end
if key % 10 == 0 then
job.percent = key/#images
end
local file_exists = known_files[image.path][image.filename]
log.msg(log.debug, image.path .. PS .. image.filename .." exists? => "..tostring(file_exists))
if (not file_exists) then
table.insert(selection, image)
end
else
break
end
end
stop_job(job)
return selection
end
local function destroy()
@@ -76,7 +114,7 @@ dt.gui.libs.select.register_selection(
local script_data = {}
script_data.metadata = {
name = "select_non_existing",
name = _("select non existing"),
purpose = _("enable selection of non-existing images"),
author = "Dirk Dittmar",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/select_non_existing"

View File

@@ -24,8 +24,6 @@ local gettext = dt.gettext.gettext
du.check_min_api_version("7.0.0", "select_untagged")
dt.gettext.bindtextdomain("select_untagged", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -35,7 +33,7 @@ end
local script_data = {}
script_data.metadata = {
name = "select_untagged",
name = _("select untagged"),
purpose = _("enable selection of untagged images"),
author = "Jannis_V",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/select_untagged"

View File

@@ -0,0 +1,199 @@
--[[
selected_image_visible.lua - make the selected image visible in lighttable
Copyright (C) 2026 Bill Ferguson <wpferguson@gmail.com>.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
]]
--[[
selected_image_visible - make the selected image visible in lighttable
selected_image_visible adds a bshortcut to make the currently
selected image or the first image of a selection visible in lighttable
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
None
USAGE
* enable the script in script_manager
BUGS, COMMENTS, SUGGESTIONS
Bill Ferguson <wpferguson@gmail.com>
CHANGES
]]
local dt = require "darktable"
local du = require "lib/dtutils"
-- local df = require "lib/dtutils.file"
-- local ds = require "lib/dtutils.string"
-- local dtsys = require "lib/dtutils.system"
local log = require "lib/dtutils.log"
-- local debug = require "darktable.debug"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A P I C H E C K
-- - - - - - - - - - - - - - - - - - - - - - - -
du.check_min_api_version("9.6.0", MODULE) -- choose the minimum version that contains the features you need
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- I 1 8 N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
local gettext = dt.gettext.gettext
local function _(msgid)
return gettext(msgid)
end
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- S C R I P T M A N A G E R I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
local script_data = {}
script_data.destroy = nil -- function to destory the script
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
script_data.show = nil -- only required for libs since the destroy_method only hides them
script_data.metadata = {
name = _("selected image visible"), -- visible name of script
purpose = _("make the selected image visible in lighttable"), -- purpose of script
author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/selected_image_visible/" -- URL to help/documentation
}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- C O N S T A N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE <const> = "selected_image_visible"
local DEFAULT_LOG_LEVEL <const> = log.info
local TMP_DIR <const> = dt.configuration.tmp_dir
-- path separator
local PS <const> = dt.configuration.running_os == "windows" and "\\" or "/"
-- command separator
local CS <const> = dt.configuration.running_os == "windows" and "&" or ";"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- L O G L E V E L
-- - - - - - - - - - - - - - - - - - - - - - - -
log.log_level(DEFAULT_LOG_LEVEL)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- N A M E S P A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
local selected_image_visible = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- G L O B A L V A R I A B L E S
-- - - - - - - - - - - - - - - - - - - - - - - -
selected_image_visible.log_level = DEFAULT_LOG_LEVEL
-- - - - - - - - - - - - - - - - - - - - - - - -
-- P R E F E R E N C E S
-- - - - - - - - - - - - - - - - - - - - - - - -
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A L I A S E S
-- - - - - - - - - - - - - - - - - - - - - - - -
local namespace = selected_image_visible
-- - - - - - - - - - - - - - - - - - - - - - - -
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
local function set_image_visible()
if dt.gui.current_view().name == "lighttable" and #dt.gui.action_images > 0 then
dt.gui.views.lighttable.set_image_visible(dt.gui.action_images[1])
end
end
local function set_button_sensitive()
if dt.gui.current_view().name == "lighttable"
and #dt.gui.selection() > 0
and not dt.gui.views.lighttable.is_image_visible(dt.gui.selection()[1]) then
dt.gui.libs.image.set_sensitive(MODULE, true)
else
dt.gui.libs.image.set_sensitive(MODULE, false)
end
end
-- - - - - - - - - - - - - - - - - - - - - - - -
-- M A I N P R O G R A M
-- - - - - - - - - - - - - - - - - - - - - - - -
-- - - - - - - - - - - - - - - - - - - - - - - -
-- U S E R I N T E R F A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.gui.libs.image.register_action(
MODULE, _("selected image visible"),
function(event, images) set_image_visible() end,
_("make selected image visible in lighttable")
)
set_button_sensitive()
-- - - - - - - - - - - - - - - - - - - - - - - -
-- D A R K T A B L E I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - -
local function destroy()
-- put things to destroy (events, storages, etc) here
dt.destroy_event(MODULE, "selection-changed")
dt.destroy_event(MODULE, "mouse-over-image-changed")
dt.gui.libs.image.destroy_action(MODULE)
end
script_data.destroy = destroy
-- - - - - - - - - - - - - - - - - - - - - - - -
-- E V E N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
if not dt.query_event(MODULE, "shortcut") then
dt.register_event(MODULE, "shortcut",
function(event, shortcut)
set_image_visible()
end, _("make selected image visible in lighttable")
)
end
dt.register_event(MODULE, "selection-changed",
function(event)
set_button_sensitive()
end
)
dt.register_event(MODULE, "mouse-over-image-changed",
function(event, image)
set_button_sensitive()
end
)
return script_data

View File

@@ -31,8 +31,6 @@ local gettext = dt.gettext.gettext
du.check_min_api_version("7.0.0", "slideshowMusic")
dt.gettext.bindtextdomain("slideshowMusic", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -42,7 +40,7 @@ end
local script_data = {}
script_data.metadata = {
name = "slideshowMusic",
name = _("slideshow music"),
purpose = _("play music during a slideshow"),
author = "Tobias Jakobs",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/slideshowMusic"

View File

@@ -0,0 +1,313 @@
--[[
toggle_group_view.lua - toggle only group visible in lighttable
Copyright (C) 2024 Bill Ferguson <wpferguson.com>.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
]]
--[[
toggle_group_view - toggle only group visible in lighttable
toggle_group_view provides a way to "separate" a group from
the lighttable display and limit the display to just the group.
Pressing the shortcut again returns to the full lighttable view.
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
None
USAGE
* place the script in your lua scripts folder
* start the script with script manager
* assign a key sequence to the shortcut
BUGS, COMMENTS, SUGGESTIONS
Bill Ferguson <wpferguson.com>
CHANGES
]]
local dt = require "darktable"
local du = require "lib/dtutils"
local log = require "lib/dtutils.log"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- C O N S T A N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE <const> = "toggle_group_view"
local DEFAULT_LOG_LEVEL <const> = log.error
local TMP_DIR <const> = dt.configuration.tmp_dir
-- path separator
local PS <const> = dt.configuration.running_os == "windows" and "\\" or "/"
-- command separator
local CS <const> = dt.configuration.running_os == "windows" and "&" or ";"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A P I C H E C K
-- - - - - - - - - - - - - - - - - - - - - - - -
du.check_min_api_version("7.0.0", MODULE) -- choose the minimum version that contains the features you need
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- I 1 8 N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
local gettext = dt.gettext.gettext
dt.gettext.bindtextdomain(MODULE , dt.configuration.config_dir .. "/lua/locale/")
local function _(msgid)
return gettext(MODULE, msgid)
end
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- S C R I P T M A N A G E R I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
local script_data = {}
script_data.destroy = nil -- function to destory the script
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
script_data.show = nil -- only required for libs since the destroy_method only hides them
script_data.metadata = {
name = "toggle_group_view", -- name of script
purpose = _("toggle only group visible in lighttable"), -- purpose of script
author = "Bill Ferguson <wpferguson.com>", -- your name and optionally e-mail address
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/toggle_group_view/" -- URL to help/documentation
}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- L O G L E V E L
-- - - - - - - - - - - - - - - - - - - - - - - -
log.log_level(DEFAULT_LOG_LEVEL)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- N A M E S P A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
local toggle_group_view = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- G L O B A L V A R I A B L E S
-- - - - - - - - - - - - - - - - - - - - - - - -
local group_active = false
local filmroll = ""
local collection_rules = {}
local hover_active = true
-- - - - - - - - - - - - - - - - - - - - - - - -
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
local function is_group_member(image)
if image then
if #image:get_group_members() > 1 then
return true
else
return false
end
end
end
local function is_group_displayed()
local rules = dt.gui.libs.collect.filter()
if rules[1].item == "DT_COLLECTION_PROP_GROUP_ID" then
local rule = dt.gui.libs.collect.new_rule()
rule.mode = "DT_LIB_COLLECT_MODE_AND"
rule.data = dt.collection[1].path
rule.item = "DT_COLLECTION_PROP_FILMROLL"
table.insert(collection_rules, rule)
group_active = true
end
end
local function set_button_sensitive(image)
local selected_images = dt.gui.selection()
local sensitive
if group_active then
dt.gui.libs.image.set_sensitive(MODULE, true)
return
end
if #selected_images > 0 then
if is_group_member(selected_images[1]) then
sensitive = true
end
else
if image then
if is_group_member(image) then
sensitive = true
else
sensitive = false
end
else
sensitive = false
end
end
dt.gui.libs.image.set_sensitive(MODULE, sensitive)
end
local function add_collection_rule(rule_string)
local rule = dt.gui.libs.collect.new_rule()
rule.mode = "DT_LIB_COLLECT_MODE_AND"
rule.data = rule_string
rule.item = "DT_COLLECTION_PROP_FILENAME"
table.insert(collection_rules, rule)
end
local function show_group(image)
local collection_rule_string = ""
local first = true
collection_rules = {}
local group_id = image.group_leader
local rule = dt.gui.libs.collect.new_rule()
rule.mode = "DT_LIB_COLLECT_MODE_AND"
rule.data = tostring(group_id.id)
rule.item = "DT_COLLECTION_PROP_GROUP_ID"
table.insert(collection_rules, rule)
-- dt.gui.libs.collect.filter returns the previous rule set when you install another
collection_rules = dt.gui.libs.collect.filter(collection_rules)
end
local function reset(image)
dt.gui.libs.collect.filter(collection_rules)
if image then
dt.gui.views.lighttable.set_image_visible(image)
else
log.msg(log.screen, _("no image supplied"))
end
set_button_sensitive(image)
end
-- - - - - - - - - - - - - - - - - - - - - - - -
-- M A I N
-- - - - - - - - - - - - - - - - - - - - - - - -
-- check if we are starting with a group already displayed and
-- configure appropriately
if not dt.preferences.read("darktable", "plugins/lighttable/act_on", "bool") then
hover_active = false
end
is_group_displayed()
-- - - - - - - - - - - - - - - - - - - - - - - -
-- U S E R I N T E R F A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.gui.libs.image.register_action(
MODULE, "toggle group view",
function(event, images)
if group_active then
local image
if dt.gui.action_images[1] then
image = dt.gui.action_images[1]
reset(dt.gui.action_images[1])
else
image = dt.collection[1]
reset(dt.collection[1])
end
group_active = false
set_button_sensitive(image)
else
show_group(dt.gui.action_images[1])
group_active = true
end
end,
_("toggle group view for selected image")
)
set_button_sensitive(nil)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- D A R K T A B L E I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - -
local function destroy()
if hover_active then
dt.destroy_event(MODULE, "mouse-over-image-changed")
end
dt.destroy_event(MODULE, "selection_changed")
dt.gui.libs.image.destroy_action(MODULE, "toggle group view")
-- leave the shortcut so it doesn't need to be reassigned
-- the next time this module starts
end
script_data.destroy = destroy
-- - - - - - - - - - - - - - - - - - - - - - - -
-- E V E N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.register_event(
MODULE, "shortcut",
function(event, shortcut)
if #dt.gui.action_images > 0 then
if group_active then
reset(dt.gui.action_images[1])
group_active = false
else
show_group(dt.gui.action_images[1])
group_active = true
end
end
end,
_("toggle group view for selected image")
)
---[[
if hover_active then
dt.register_event(MODULE, "mouse-over-image-changed",
function(event, image)
set_button_sensitive(image)
end
)
end
--]]
dt.register_event(MODULE, "selection-changed",
function(event)
set_button_sensitive(dt.gui.action_images[1])
end
)
return script_data

View File

@@ -76,14 +76,13 @@ local darktable = require("darktable")
local dtutils = require("lib/dtutils")
local dtutils_file = require("lib/dtutils.file")
local dtutils_system = require("lib/dtutils.system")
local gettext = darktable.gettext.gettext
local LIB_ID = "transfer_hierarchy"
dtutils.check_min_api_version("7.0.0", LIB_ID)
darktable.gettext.bindtextdomain("transfer_hierarchy", darktable.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return darktable.gettext.gettext(msgid)
return gettext(msgid)
end
-- return data structure for script_manager
@@ -91,7 +90,7 @@ end
local script_data = {}
script_data.metadata = {
name = "transfer_hierarchy",
name = _("transfer hierarchy"),
purpose = _("allows the moving or copying of images from one directory tree to another, while preserving the existing hierarchy"),
author = "August Schwerdfeger (august@schwerdfeger.name)",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/transfer_hierarchy"
@@ -145,7 +144,7 @@ end
local function install_module()
if not th.module_installed then
darktable.register_lib(LIB_ID,
"transfer hierarchy", true, true, {
_("transfer hierarchy"), true, true, {
[darktable.gui.views.lighttable] = { "DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 700 }
}, th.transfer_widget, nil, nil)
th.module_installed = true
@@ -271,7 +270,7 @@ local function doTransfer(transferFunc)
local sourceBase = sourceTextBox.text
local destBase = destinationTextBox.text
local destFilms = {}
for film, _ in pairs(films) do
for film, i in pairs(films) do
films[film] = destBase .. string.sub(film.path, #sourceBase+1)
if not pathExists(films[film]) then
if createDirectory(films[film]) == nil then

1066
contrib/ultrahdr.lua Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -41,8 +41,6 @@ local gettext = dt.gettext.gettext
du.check_min_api_version("7.0.0", "video_ffmpeg")
dt.gettext.bindtextdomain("video_ffmpeg", dt.configuration.config_dir .."/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
@@ -52,7 +50,7 @@ end
local script_data = {}
script_data.metadata = {
name = "video_ffmpeg",
name = _("video ffmpeg"),
purpose = _("timelapse video plugin based on ffmpeg"),
author = "Dominik Markiewicz",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contib/video_ffmpeg"
@@ -248,8 +246,8 @@ local function string_pref_write(name, widget_attribute)
end
local framerates_selector = dt.new_widget("combobox"){
label = _("framerate"),
tooltip = _("select framerate of output video"),
label = _("frame rate"),
tooltip = _("select frame rate of output video"),
value = combobox_pref_read("framerate", framerates),
changed_callback = combobox_pref_write("framerate"),
table.unpack(framerates)
@@ -418,7 +416,7 @@ local function export(extra_data)
local ffmpeg_path = df.check_if_bin_exists("ffmpeg")
if not ffmpeg_path then
dt.print_error("ffmpeg not found")
dt.print("ERROR - ffmpeg not found")
dt.print(_("ERROR - ffmpeg not found"))
return
end
local dir = extra_data["tmp_dir"]
@@ -450,7 +448,7 @@ local function finalize_export(storage, images_table, extra_data)
dt.print_error(filename, file.filename)
df.file_move(filename, tmp_dir .. PS .. i .. extra_data["img_ext"])
end
dt.print("Start video building...")
dt.print(_("start video building..."))
local result, path = export(extra_data)
if result ~= 0 then
dt.print(_("ERROR: cannot build image, see console for more info"))

Some files were not shown because too many files have changed in this diff Show More