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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/TEMPLATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ NegPy uses **Jinja2** for dynamic file naming in both the **Export** and **Scan*
| `{{ push_pull }}` | Push/pull as an integer (−3…+3, 0 = Normal). | `1` |
| `{{ scanning }}` | Scanning method note. | `DSLR copy-stand` |
| `{{ exposure }}` | Exposure override text from Metadata. | `1/125s f/2.8` |
| `{{ capture_date }}` | Original capture date in YYYYMMDD. A partial date pads to the first day. Empty if unset. | `19980714` |
| `{{ capture_year }}` | Original capture year. Empty if unset. | `1998` |

Gear and process values come from the **Metadata** panel, or from each file's saved metadata in a batch. An empty field renders as an empty string, so the separators around it collapse. NegPy strips path-unsafe characters from metadata values.

Expand Down
11 changes: 9 additions & 2 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ Type a plain word and it matches the filename. Beyond that the box takes `field:
| `camera:"Nikon F3"` | quote anything with a space |
| `iso:>=400` | numeric fields also take `>`, `>=`, `<`, `<=` (`iso`, `frame`, `push`) |
| `date:2024-03` · `date:>=2024` | by file date; a partial date is a prefix |
| `shot:1998` · `shot:>=1998-07` | by capture date from the Metadata panel, not the file date |
| `place:tokyo` | by capture city, state or country |
| `roll:` `developer:` `lens:` `format:` `scanning:` | the rest of the Metadata panel |
| `name:` `path:` `ext:tif` | file identity |
| `keeper:` `rejected:` `edited:` | frames carrying that mark, or with a saved edit |
Expand Down Expand Up @@ -812,13 +814,19 @@ The primary **Export** action. Its chevron menu picks the scope: current frame (
Archival metadata for the **original analog capture** (camera, lens, film, process), written into exported files as EXIF and embedded XMP, so DAMs like Lightroom show your film gear rather than the scanner.

* **Protect original metadata**: copy the source file's EXIF/XMP to exports unchanged, adding nothing. When it is on, the fields below are ignored.
* **Sync custom metadata to all files in batch export**: batch and preset exports write this frame's capture, gear and process values to every file, instead of each file's own.

**Analog Gear** (searchable; type in any field to filter the library):

* **Preset**: a reusable camera, lens and film combination. **Clear** empties the gear selections.
* **Camera / Lens / Film stock**: pick from your library. Empty means not set.
* **Manage…**: edit cameras, lenses, film stocks and presets. Starter data seeds into `~/NegPy/gear/` on first launch.

**Capture:**

* **Date**: when the frame was shot — give only what you know: `1998`, `1998-07`, `1998-07-14` or `1998-07-14 16:30`, with an optional offset such as `+02:00`. An impossible date turns the field red and is not saved. EXIF `DateTimeOriginal` pads the missing parts; XMP `photoshop:DateCreated` keeps the truncated form and `negpy:CaptureDatePrecision` names it. The scan file's own timestamp moves to `DateTimeDigitized`.
* **Place**: the capture location. The map-pin button opens a map to search a place name, click a position or paste coordinates, the ✕ beside it empties the place, and the field itself accepts a pasted coordinate pair or an OpenStreetMap/Google Maps link. Coordinates are written to the EXIF GPS tags and XMP `exif:GPS*`, the names to XMP `photoshop:City`/`State`/`Country`; a TIFF carries the location in XMP only, and a place you set replaces the source file's GPS block whole, rather than leaving its altitude or heading beside your coordinates. A geotagged source with no place set here keeps its own coordinates on export, and the map opens centred on them — where the frame was digitized is a starting view, never the capture place. Opening the map contacts OpenStreetMap; typing coordinates needs no network.

**Process:**

* **Format**: `35mm`, `120`, `4×5`, `8×10`, `110`, or `Other` with a free-text field.
Expand All @@ -829,11 +837,10 @@ Archival metadata for the **original analog capture** (camera, lens, film, proce

* **Scanning**: scan method or notes. EXIF `Software` is always `NegPy`.
* **Roll / Frame**: Scanlight capture roll name and frame number, stamped automatically on capture and editable here. Available in export filename templates as `{{ roll }}` and `{{ frame }}`, and written to XMP as `negpy:CaptureRoll` and `negpy:CaptureFrame` when set. Not the Roll Analysis normalization name.
* **Sync custom metadata to all files in batch export**: apply this tab's values to every file in a batch.

**Exposure**: optional original shutter, aperture and ISO. Click the lock to edit a free-text string, for example `1/125s f/2.8 ISO 400`.

**Metadata preview**: a live view of exactly what will be embedded, grouped by capture, scan, process and file. **Description…** opens a checklist of which fields join into EXIF `ImageDescription`. The defaults are camera, lens, film stock and ISO; format, developer, push/pull and scanning are off until you enable them. Confirming **Description…** sets that frame's selection and becomes the sticky default for other frames that do not have their own, so the last confirm on the roll wins. Sync metadata and Sync settings can also copy a frame's selection with the rest of the metadata.
**Metadata preview**: a live view of exactly what will be embedded, grouped by capture, scan, process and file. The Scan group shows the source file's own timestamp and coordinates, so you can see what you are replacing. **Description…** opens a checklist of which fields join into EXIF `ImageDescription`. The defaults are camera, lens, film stock and ISO; format, developer, push/pull and scanning are off until you enable them. Confirming **Description…** sets that frame's selection and becomes the sticky default for other frames that do not have their own, so the last confirm on the roll wins. Sync metadata and Sync settings can also copy a frame's selection with the rest of the metadata.

When you set capture gear, it is written to standard EXIF, and the digitizing rig is preserved separately in `negpy:Scan*` XMP tags. Leave gear unset and your scanner or DSLR stays visible in EXIF instead.

Expand Down
12 changes: 12 additions & 0 deletions negpy/desktop/settings_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from typing import Any, Callable, Iterable, Mapping, Optional

from negpy.domain.models import WorkspaceConfig
from negpy.features.metadata.capture import place_summary
from negpy.features.metadata.models import PUSH_PULL_LABELS
from negpy.features.process.models import invalidate_local_bounds

Expand Down Expand Up @@ -207,6 +208,17 @@ def _row(label, section, *fields, channels="", fmt=None) -> SettingRow:
_row("Film Manufacturer", "metadata", "film_manufacturer"),
_row("Film Color Type", "metadata", "film_color_type"),
_row("Format", "metadata", "format", "format_other", fmt=lambda v: (v[1] if v[0] == "Other" and v[1] else v[0]) or "—"),
_row("Capture Date", "metadata", "capture_date"),
_row(
"Place",
"metadata",
"location_city",
"location_state",
"location_country",
"gps_latitude",
"gps_longitude",
fmt=lambda v: place_summary(v[0], v[1], v[2], v[3], v[4]) or "—",
),
_row("Developer", "metadata", "developer"),
_row("Push/Pull", "metadata", "push_pull", fmt=lambda v: PUSH_PULL_LABELS.get(v[0], str(v[0]))),
_row("Scanning", "metadata", "scanning"),
Expand Down
4 changes: 2 additions & 2 deletions negpy/desktop/view/sidebar/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -536,8 +536,8 @@ def _init_ui(self) -> None:
self.search_input.setToolTip(
"Filter the sheet. A bare word matches the filename; terms are combined with AND.\n"
"Fields: film, camera, lens, developer, format, scanning, roll, frame, iso, push,\n"
"name, path, ext, date, keeper, rejected, edited.\n"
'Examples: film:portra iso:>=400 · camera:"Nikon F3" -rejected: · date:>=2024-03'
"shot, place, name, path, ext, date, keeper, rejected, edited.\n"
'Examples: film:portra iso:>=400 · camera:"Nikon F3" -rejected: · shot:>=1998-07 · place:tokyo'
)
self.search_input.setClearButtonEnabled(True)
self.search_input.addAction(
Expand Down
148 changes: 139 additions & 9 deletions negpy/desktop/view/sidebar/metadata.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import qtawesome as qta
from dataclasses import asdict, replace
from typing import Optional
from PyQt6.QtCore import QTimer
from PyQt6.QtWidgets import (
QCheckBox,
Expand All @@ -19,7 +20,15 @@
from negpy.desktop.view.widgets.collapsible import CollapsibleSection
from negpy.desktop.view.widgets.description_fields_dialog import DescriptionFieldsDialog
from negpy.desktop.view.widgets.gear_library_dialog import GearLibraryDialog
from negpy.desktop.view.widgets.location_picker_dialog import LocationPickerDialog
from negpy.desktop.view.widgets.searchable_gear_combo import SearchableGearCombo
from negpy.features.metadata.capture import (
CAPTURE_DATE_HINT,
parse_capture_date,
parse_coords,
place_summary,
)
from negpy.features.metadata.exif_read import extract_scan_from_exif
from negpy.features.metadata.gear_logic import metadata_from_gear
from negpy.features.metadata.gear_models import GearLibrary
from negpy.features.metadata.models import DEFAULT_DESCRIPTION_FIELDS
Expand Down Expand Up @@ -62,6 +71,13 @@ def _init_ui(self) -> None:
)
self.layout.addWidget(self.protect_check)

self.sync_check = QCheckBox("Sync custom metadata to all files in batch export")
self.sync_check.setChecked(conf.sync_to_batch)
self.sync_check.setToolTip(
"Batch and preset exports write this frame's capture, gear and process values to every file, instead of each file's own."
)
self.layout.addWidget(self.sync_check)

self._metadata_controls = QWidget()
controls = QVBoxLayout(self._metadata_controls)
controls.setContentsMargins(0, 0, 0, 0)
Expand Down Expand Up @@ -103,6 +119,32 @@ def _init_ui(self) -> None:
gear.addWidget(self.manage_btn)
controls.addWidget(self._card("Analog Gear", "gear", gear_body, "fa5s.camera-retro"))

# ── CAPTURE ──────────────────────────────────────────────────────
cap_body, cap = self._card_body()
cap.addWidget(field_label("Date"))
self.capture_date_edit = QLineEdit()
self.capture_date_edit.setPlaceholderText(CAPTURE_DATE_HINT)
self.capture_date_edit.setText(conf.capture_date)
self.capture_date_edit.setToolTip(
"When the frame was shot. Give only what you know: a year, a year and month, "
"a date, or a date and time. An offset like +02:00 may follow a time."
)
cap.addWidget(self.capture_date_edit)

cap.addWidget(field_label("Place"))
place_row = QHBoxLayout()
place_row.setSpacing(THEME.space_sm)
self.place_edit = QLineEdit()
self.place_edit.setPlaceholderText("Pick on a map, or paste coordinates")
self.place_edit.setToolTip("Capture place. Paste a coordinate pair or a map link here, or use Map… to pick one.")
place_row.addWidget(self.place_edit, 1)
self.place_map_btn = self._icon_action("fa5s.map-marked-alt", "Pick the capture place on a map (contacts OpenStreetMap)")
place_row.addWidget(self.place_map_btn)
self.place_clear_btn = self._icon_action("fa5s.times", "Clear the capture place")
place_row.addWidget(self.place_clear_btn)
cap.addLayout(place_row)
controls.addWidget(self._card("Capture", "capture", cap_body, "fa5s.clock"))

# ── PROCESS ──────────────────────────────────────────────────────
proc_body, proc = self._card_body()
proc.addWidget(field_label("Format"))
Expand Down Expand Up @@ -163,9 +205,6 @@ def _init_ui(self) -> None:
roll_row.addLayout(frame_col, 1)
scan.addLayout(roll_row)

self.sync_check = QCheckBox("Sync custom metadata to all files in batch export")
self.sync_check.setChecked(conf.sync_to_batch)
scan.addWidget(self.sync_check)
controls.addWidget(self._card("Scanning", "scanning", scan_body, "mdi6.scanner"))

# ── EXPOSURE ─────────────────────────────────────────────────────
Expand Down Expand Up @@ -250,6 +289,7 @@ def _make_exif_field(self, key: str, layout: QVBoxLayout) -> QLineEdit:
def _set_metadata_controls_enabled(self, enabled: bool) -> None:
self._metadata_controls.setEnabled(enabled)
self.description_fields_btn.setEnabled(enabled)
self.sync_check.setEnabled(enabled)

def _apply_lock_style(self, edit: QLineEdit, locked: bool) -> None:
if locked:
Expand Down Expand Up @@ -285,6 +325,11 @@ def _connect_signals(self) -> None:
self.film_stock_combo.selection_changed.connect(self._on_gear_changed)
self.manage_btn.clicked.connect(self._open_gear_library)

self.capture_date_edit.textChanged.connect(self._on_capture_date_changed)
self.place_edit.editingFinished.connect(self._on_place_edited)
self.place_map_btn.clicked.connect(self._open_location_picker)
self.place_clear_btn.clicked.connect(self._on_place_clear)

self.format_combo.currentTextChanged.connect(self._on_format_changed)
self.format_other_edit.textChanged.connect(self._mark_dirty)
self.developer_edit.textChanged.connect(self._mark_dirty)
Expand Down Expand Up @@ -456,6 +501,85 @@ def _mark_dirty(self) -> None:
def _schedule_preview(self) -> None:
self.preview_timer.start()

def _on_capture_date_changed(self, text: str) -> None:
valid = not text.strip() or parse_capture_date(text) is not None
self.capture_date_edit.setStyleSheet("" if valid else f"border: 1px solid {THEME.accent_secondary};")
self._mark_dirty()

def _source_exif(self) -> Optional[dict]:
current_hash = self.state.current_file_hash
if current_hash and current_hash in self.state.source_exif:
return self.state.source_exif[current_hash]
return None

def _place_text(self) -> str:
conf = self.state.config.metadata
return place_summary(
conf.location_city,
conf.location_state,
conf.location_country,
conf.gps_latitude,
conf.gps_longitude,
)

def _apply_location(self, lat, lon, city: str, state: str, country: str) -> None:
self.update_config_section(
"metadata",
persist=True,
render=False,
readback_metrics=False,
gps_latitude=lat,
gps_longitude=lon,
location_city=city,
location_state=state,
location_country=country,
)
self._set_place_text_quiet()
self._schedule_preview()

def _set_place_text_quiet(self) -> None:
self.place_edit.blockSignals(True)
try:
self.place_edit.setText(self._place_text())
finally:
self.place_edit.blockSignals(False)

def _on_place_edited(self) -> None:
"""Typed text is only ever coordinates; place names come from the picker."""
text = self.place_edit.text().strip()
if not text:
self._on_place_clear()
return
coords = parse_coords(text)
if coords is None:
self._set_place_text_quiet()
return
conf = self.state.config.metadata
self._apply_location(coords[0], coords[1], conf.location_city, conf.location_state, conf.location_country)

def _on_place_clear(self) -> None:
self._apply_location(None, None, "", "", "")

def _open_location_picker(self) -> None:
conf = self.state.config.metadata
center = None
if conf.gps_latitude is None or conf.gps_longitude is None:
scan = extract_scan_from_exif(self._source_exif())
if scan.gps_latitude is not None and scan.gps_longitude is not None:
center = (scan.gps_latitude, scan.gps_longitude)
dlg = LocationPickerDialog(
conf.gps_latitude,
conf.gps_longitude,
conf.location_city,
conf.location_state,
conf.location_country,
center=center,
parent=self,
)
if dlg.exec() != dlg.DialogCode.Accepted:
return
self._apply_location(*dlg.location())

def _on_format_changed(self, text: str) -> None:
self.format_other_edit.setVisible(text == "Other")
self._mark_dirty()
Expand All @@ -480,11 +604,16 @@ def _persist_all_metadata_settings(self) -> None:
except ValueError:
capture_frame = self.state.config.metadata.capture_frame

date_text = self.capture_date_edit.text().strip()
parsed_date = parse_capture_date(date_text)
capture_date = parsed_date.xmp_text() if parsed_date else ("" if not date_text else self.state.config.metadata.capture_date)

self.update_config_section(
"metadata",
persist=True,
render=False,
readback_metrics=False,
capture_date=capture_date,
gear_preset_id=self.preset_combo.selected_id(),
camera_id=self.camera_combo.selected_id(),
lens_id=self.lens_combo.selected_id(),
Expand Down Expand Up @@ -518,6 +647,9 @@ def sync_ui(self) -> None:
self.format_combo.setCurrentText("Other")
self.format_other_edit.setText(conf.format_other)
self.format_other_edit.setVisible(self.format_combo.currentText() == "Other")
self.capture_date_edit.setText(conf.capture_date)
self.capture_date_edit.setStyleSheet("")
self.place_edit.setText(self._place_text())
self.developer_edit.setText(conf.developer)
idx = PUSH_PULL_VALUES.index(conf.push_pull) if conf.push_pull in PUSH_PULL_VALUES else 3
self.push_pull_combo.setCurrentIndex(idx)
Expand Down Expand Up @@ -579,8 +711,11 @@ def _preview_metadata_config(self):
else:
exposure_override = conf.exposure_override

parsed_date = parse_capture_date(self.capture_date_edit.text())

return replace(
conf,
capture_date=parsed_date.xmp_text() if parsed_date else "",
gear_preset_id=self.preset_combo.selected_id(),
camera_id=self.camera_combo.selected_id(),
lens_id=self.lens_combo.selected_id(),
Expand Down Expand Up @@ -608,12 +743,7 @@ def _update_preview(self) -> None:
self.preview_section.setEnabled(True)
return

source_exif = None
current_hash = self.state.current_file_hash
if current_hash and current_hash in self.state.source_exif:
source_exif = self.state.source_exif[current_hash]

payload = build_metadata_payload(self._preview_metadata_config(), self._gear_library, source_exif)
payload = build_metadata_payload(self._preview_metadata_config(), self._gear_library, self._source_exif())
sections = payload.to_preview_sections()

self.preview_empty.setText("Select gear or enter process metadata to see a preview.")
Expand Down
Loading