Skip to content

Bulk Export: Add CATEGORY_EACH variable and - #22318

Open
clawoflight wants to merge 5 commits into
darktable-org:masterfrom
clawoflight:multi-path-export
Open

clawoflight wants to merge 5 commits into
darktable-org:masterfrom
clawoflight:multi-path-export

Conversation

@clawoflight

Copy link
Copy Markdown

Summary

This allows proper bulk export using hierarchical tags.
Previously, if an image had multiple tag values, you would get one directory for every combination of values.
Now, $(CATEGORIES_EACH[]) gives you one directory per tag value.
This makes it easy to, e.g., export images into directories for people, or scene types (where each image could reasonably contain more than one).

This applies to the disk backend only. I can add gallery if wanted, the others probably don't make sense at all.

Referenced issue

Closes: #22317

Checklist

  • I have read CONTRIBUTING.md and the
    coding style.
  • I don't have merged master into the topic branch.
  • The pull request is one logical change, and every commit compiles on its
    own.
  • I ran the relevant tests: unit tests, src/tests/integration/ where the
    pixelpipe is touched, or darktable-cli as a headless smoke test.
  • New user-visible strings use _(), new preferences are registered in
    data/darktableconfig.xml.in.
  • A RELEASE_NOTES.md entry was added.

Test instructions

  1. The 6 new tests in src/tests/variables.c clearly show the intended use
  2. Tag an image with two hierarchical tags, e.g., Person|Janeand Person|John.
  3. Export using $(CATEGORY[0, Person]): This expands to one file in "Jane, John"
  4. Now export using $(CATEGORY_EACH[0, Person]): This expands to two files, one in "John", one in "Jane"

AI assistance

This MR was assisted by Deepseek V4 Flash via OpenCode. Everything was tested manually in addition to the unit tests.

Scope

We could easily add the following if we want, now that variables.c uses GList * instead of char *:

  • TAGS_EACH
  • IMAGE.TAGS.HIERARCHY_EACH
  • LABELS_EACH

@wpferguson

Copy link
Copy Markdown
Member

It might be a good idea to figure out if it's a bug, a regression, a misunderstanding, etc before jumping to fix it. If it's a regression, then we are just slapping a band aid over the problem to hide it rather than fix it.

This can sit, but #22317 needs to be understood so that we know what to fix.

@wpferguson
wpferguson requested a review from zisoft September 17, 2026 22:44
@wpferguson wpferguson added feature: new new features to add scope: UI user interface and interactions scope: DAM managing files, collections, archiving, metadata, etc. labels Sep 17, 2026
@wpferguson

Copy link
Copy Markdown
Member

I tested using the following tags and tagged 6 image

  • Person|Allen|Jane
  • Person|Allen|John|Jr
  • Person|Allen|John|Sr

I used the variable line $(FILE_FOLDER)/darktable_exported/$(CATEGORY_EACH[0,Person])/$(CATEGORY_EACH[1,Person])/$(CATEGORY_EACH[2,Person])/$(FILE_NAME)

The result I expected was

darktable_exported/Allen/Jane - 6 images
darktable_exported/Allen/John/Jr - 6 images
darktable_exported/Allen/John/Sr - 6 images

What I got was

darktable_exported/Allen/Jane/Jr - 6 images
darktable_exported/Allen/Jane/Sr - 6 images
darktable_exported/Allen/John/Jr - 6 images
darktable_exported/Allen/John/Sr - 6 images

@clawoflight

Copy link
Copy Markdown
Author

It might be a good idea to figure out if it's a bug, a regression, a misunderstanding, etc before jumping to fix it. If it's a regression, then we are just slapping a band aid over the problem to hide it rather than fix it.

This can sit, but #22317 needs to be understood so that we know what to fix.

Thank you so much for taking a look so quickly!

This is not a regression. It seems to be a feature gap that was never covered.
I found two references to people wanting something like this when I tried to see if this was possible:

#12468
https://www.reddit.com/r/DarkTable/comments/1azpyzo/how_do_i_sort_and_export_with_category_feature/

In the code, I couldn't find any sign that this ever was intended to work (which is why I had to make some functions return GList* instead of strings). If anything, the existing behavior was the band-aid :)

@clawoflight

clawoflight commented Sep 18, 2026

Copy link
Copy Markdown
Author

Thank you for your test. My current design used a Cartesian product over the values, which makes sense for independent tags, but is unintuitive for nested hierarchies.

I can think of 2 ways to make this work. Which do you prefer:

  1. We expand hierarchical tags up to the given level instead of at the given level.
    For your case, one would enter $(FILE_FOLDER)/darktable_exported/$(CATEGORY_EACH[2,Person])/$(FILE_NAME)
  2. Correlate identical sources instead of multiplying them, and dedupe results. That way, the variable line you attempted would work. This looks like a bit more work, but we would get a more flexible UX in the variable line.

Edit: I pushed approach 2 in appended commits so you can play with that behavior. I will of course clean up the history once we decided how to proceed :)

@wpferguson

Copy link
Copy Markdown
Member

My desired output was surname/given name/generation if it exists/file name. That way families (surame) are grouped together.

Now I have a question about blast radius.

Variable substitution is used in more that just the exporter. It's also used in thumbnail overlays, darkroom image information, etc. Look through the preferences for anywhere strings use variable substitution. We need to make sure those don't break with multiple string results and how to display them. This may be why the categories got joined with a comma instead of being split into multiple strings.

@clawoflight

Copy link
Copy Markdown
Author

The blast radius should be minimal: Only the new CATEGORY_EACH creates lists, and only in the imageio/storage/disk.c backend (dt_variables_expand_path_multi()).

In other contexts, CATEGORY_EACH behaves like CATEGORY.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Core list expansion and disk export behavior still contain correctness and memory-safety defects.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity · 4 Medium severity

Open (5)
What changed in this PR

Adds disk bulk-export support for creating one output path per hierarchical tag value.

Changes:

  • Adds CATEGORY_EACH and multi-path variable expansion.
  • Updates disk exports to process multiple destinations.
  • Adds tests, UI completion, and release documentation.
File Description
src/​tests/​variables.c Tests list-valued path expansion.
src/​imageio/​storage/​disk.c Exports images to multiple expanded paths.
src/​gui/​gtkentry.c Adds variable completion help.
src/​common/​variables.h Declares the multi-path API.
src/​common/​variables.c Implements list-valued expansion.
src/​common/​tags.h Declares tag-list helpers.
src/​common/​tags.c Retrieves hierarchical tag paths.
RELEASE_NOTES.md Documents the new export variable.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/common/variables.c Outdated
Comment thread src/common/tags.c Outdated
Comment thread src/common/variables.c Outdated
Comment thread src/common/variables.c Outdated
Comment thread src/imageio/storage/disk.c
Returns, for every attached tag under a category, the components below
that category as a NULL-terminated array. The multi expander needs the
whole tag, not the distinct subtags at one level, so that repeated
references to the same category can walk one tag's path.

dt_tag_get_subtags() is reimplemented on top of a new
dt_tag_get_subtags_list(), which also fixes its substring-based
de-duplication and orders the subtags by name.
CATEGORY collapses every matching subtag into one comma-separated
string, so an image tagged Person|John and Person|Jane exports into a
single "John,Jane" directory instead of one directory per person.

Add CATEGORY_EACH[n,category], which yields the level-n component of
each matching tag, and dt_variables_expand_path_multi(), which expands a
path pattern once per tag. References to the same category select fields
of one tag and so walk that tag's path, so
$(CATEGORY_EACH[0,Person])/$(CATEGORY_EACH[1,Person])/$(CATEGORY_EACH[2,Person])
yields Allen/Jane, Allen/John/Jr and Allen/John/Sr. Distinct categories
stay independent axes and still multiply. The product is capped at 1024
combinations; beyond that the expansion fails and the caller is expected
to abort.

The expander stays single-valued: the multi API owns the enumeration.
Outside it CATEGORY_EACH collapses to the comma-joined scalar, so it is
harmless in every other expansion path.
The file-on-disk storage expanded the filename pattern once, so a
pattern with $(CATEGORY_EACH) only ever produced the comma-joined
scalar. Use dt_variables_expand_path_multi() and loop over the paths it
returns, writing the image once per tag.

The mkdir, writability and conflict handling now run per output, so a
skipped output no longer skips the whole image. Paths are still decided
under the plugin lock, but the exports themselves run after it is
released: dt_imageio_export() takes the same lock and would deadlock.
@TurboGit

Copy link
Copy Markdown
Member

@wpferguson : Do you confirm that your expected output is now what we get with this PR?

We introduce a new tag $(CATEGORY_EACH) which acts as an iterator. I'm a bit concerned that the whole "iteration" is done in disk backend. I would certainly make this more generic and be able to iterate over the values (export paths) and call a single function to do the export (export with setting or with presets). As reading the diff code is not easy maybe that's not possible or factorized as much as possible already? But again, my idea would be to be able to support this for other backends without too much code duplication.

@clawoflight

Copy link
Copy Markdown
Author

I'm a bit concerned that the whole "iteration" is done in disk backend. I would certainly make this more generic and be able to iterate over the values (export paths) and call a single function to do the export (export with setting or with presets). As reading the diff code is not easy maybe that's not possible or factorized as much as possible already? But again, my idea would be to be able to support this for other backends without too much code duplication.

@TurboGit Salut!
The expansion to a list is in the following function (so locations can opt in to non-scalar behavior): dt_variables_expand_path_multi() in variables.c

Only the handling of each path is backend-specific - I figured this wouldn't make sense for most backups, except maybe the gallery. The iteration could maybe be moved to control_jobs.c, but then this completely changes the API for every backend. This seemed like a good middle-ground to me.

@zisoft

zisoft commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator

I would certainly make this more generic and be able to iterate over the values (export paths) and call a single function to do the export (export with setting or with presets).

Wouldn't that mean to call the export multiple times with identical parameters although only the export path/filename has changed?

@wpferguson

wpferguson commented Sep 20, 2026

Copy link
Copy Markdown
Member

@TurboGit the export works correctly and I get the expected results.

In my contrived example, each image is exported multiple times (6 images x 3 tag combinations = 18 images). I don't know if there is a way to export once and copy twice that makes sense. I could see the combinations of tags getting out of hand, especially if you make a mistake, and exports taking a lllllooooonnnnnnngggggg time.

I don't know if multiple results makes sense in any context except export. I say that and as I'm saying it I think of one. I could see an image overlay with everyone that is tagged being shown so that you know who you tagged and who you didn't. But, how do we show it and how do we handle it? Return a comma separated string with the results and use ellipsis when the string overflows the overlay? So CATEGORY_EACH would have to be aware of how and where it was being used and return the context correct result.

EDIT:

Return a comma separated string with the results

I think CATEGORY would do this, so this is probably already handled.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature: new new features to add scope: DAM managing files, collections, archiving, metadata, etc. scope: UI user interface and interactions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bulk Export: Allow exporting to one directory per tag value instead of one directory per combination of values

5 participants