Add darktable ghost publish plugin

Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
This commit is contained in:
2024-12-22 21:39:55 +02:00
parent 98ce774210
commit f44dac4158
17 changed files with 2624 additions and 19 deletions

View File

@@ -2,7 +2,10 @@
user ? throw "user argument is required",
home ? throw "home argument is required",
}:
{ pkgs, ... }:
{ config, pkgs, ... }:
let
hmConfig = config.home-manager.users.${user};
in
{
nixpkgs.overlays = [
(final: prev: {
@@ -13,12 +16,22 @@
];
environment.persistence = {
"/persist"."${home}/.config/darktable" = { };
"/persist" = {
"${home}/.config/darktable/data.db" = { };
"${home}/.config/darktable/library.db" = { };
};
"/cache"."${home}/.cache/darktable" = { };
};
home-manager.users.${user} =
let
lua-scripts = pkgs.fetchFromGitHub {
owner = "darktable-org";
repo = "lua-scripts";
rev = "daa0877b4c25b91e4b71afc1ef8ffcba6018f7b2";
sha256 = "sha256-NNGAq1zgKqWLhKBPgm7kFZq4xwvescxnCAwovSF9r4k=";
};
hald-clut = pkgs.fetchFromGitHub {
owner = "cedeber";
repo = "hald-clut";
@@ -27,7 +40,18 @@
};
in
{
home.packages = with pkgs; [ darktable ];
home = {
packages = with pkgs; [
darktable
exiftool
(pkgs.callPackage ./publish { })
];
sessionVariables = {
GHOST_URL = "https://photos.karaolidis.com";
GHOST_ADMIN_API_KEY_PATH = hmConfig.sops.secrets."jupiter/photos.karaolidis.com/admin".path;
};
};
xdg.configFile = {
"darktable/darktablerc".source = (pkgs.formats.keyValue { }).generate "darktablerc" {
@@ -48,9 +72,22 @@
"$(EXIF.YEAR)-$(EXIF.MONTH)-$(EXIF.DAY)_$(EXIF.HOUR)-$(EXIF.MINUTE)-$(EXIF.SECOND)_$(CONFLICT_PADDING).$(FILE_EXTENSION)";
"session/sub_directory_pattern" = "";
"setup_import_directory" = true;
"lua/script_manager/check_update" = false;
};
"darktable/luarc".text = ''
require "tools/script_manager"
require "tools/publish"
'';
"darktable/lua/lib".source = "${lua-scripts}/lib";
"darktable/lua/tools/script_manager.lua".source = "${lua-scripts}/tools/script_manager.lua";
"darktable/lua/tools/publish.lua".source = ./publish/publish.lua;
"darktable/luts".source = "${hald-clut}/HaldCLUT";
};
sops.secrets."jupiter/photos.karaolidis.com/admin".sopsFile =
../../../../../../secrets/personal/secrets.yaml;
};
}

View File

@@ -0,0 +1,2 @@
node_modules/
build/

Binary file not shown.

View File

@@ -0,0 +1,29 @@
{ pkgs, lib, ... }:
pkgs.stdenv.mkDerivation rec {
pname = "darktable-publish";
version = "1.0.0";
src = ./.;
npmSrc = pkgs.buildNpmPackage ({
inherit src pname version;
npmDepsHash = "sha256-vBJIIuryC/zRvp9oKBVuCDTycPOpzgsLebU55CiIb7I=";
dontNpmBuild = true;
installPhase = ''
cp -r . $out
'';
});
# FIXME: https://github.com/NixOS/nixpkgs/issues/255890
wrapper = pkgs.writeShellApplication {
name = pname;
runtimeInputs = with pkgs; [ bun ];
text = ''
bun ${npmSrc}/src/index.ts "$@"
'';
};
installPhase = ''
mkdir -p $out/bin
cp ${lib.meta.getExe wrapper} $out/bin/
'';
}

View File

@@ -0,0 +1,11 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
/** @type {import('eslint').Linter.Config[]} */
export default [
{ files: ["**/*.{js,mjs,cjs,ts}"] },
{ languageOptions: { globals: globals.browser } },
pluginJs.configs.recommended,
...tseslint.configs.recommended,
];

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
{
"name": "publish",
"module": "src/index.ts",
"type": "module",
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/bun": "latest",
"@types/jsonwebtoken": "^9.0.7",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"globals": "^15.14.0",
"prettier": "^3.4.2",
"typescript-eslint": "^8.18.1"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"commander": "^12.1.0",
"exiftool-vendored": "^29.0.0",
"jsonwebtoken": "^9.0.2"
}
}

View File

@@ -0,0 +1,133 @@
local dt = require "darktable"
local df = require "lib/dtutils.file"
local os = require "os"
-- Some fucking bullshit happening right here.
function os.capture(command, raw)
local f = assert(io.popen(command, 'r'))
local s = assert(f:read('*a'))
f:close()
if raw then return s end
s = string.gsub(s, '^%s+', '')
s = string.gsub(s, '%s+$', '')
s = string.gsub(s, '[\n\r]+', ' ')
return s
end
local publish_title = dt.new_widget("entry") {
placeholder = "Post Title",
tooltip = "enter title for the post"
}
local publish_slug = dt.new_widget("entry") {
placeholder = "post-slug",
tooltip = "enter slug for the post (URL-friendly)"
}
local publish_keywords = dt.new_widget("entry") {
placeholder = "keywords (space-separated)",
tooltip = "enter keywords (tags) for the post"
}
local strip_gps_checkbox = dt.new_widget("check_button") {
label = "Strip GPS data",
value = false,
tooltip = "remove GPS metadata from files before uploading"
}
local widget = dt.new_widget("box") {
orientation = "vertical",
publish_title,
publish_slug,
publish_keywords,
strip_gps_checkbox
}
local function initialize(storage, format, images, high_quality, extra_data)
extra_data.exported_files = {}
extra_data.cleanup_files = {}
if publish_title.text == "" then
extra_data.title = df.get_basename(images[1].filename)
else
extra_data.title = publish_title.text
end
if publish_slug.text == "" then
extra_data.slug = df.get_basename(images[1].filename)
else
extra_data.slug = publish_slug.text
end
extra_data.keywords = publish_keywords.text
extra_data.strip_gps = strip_gps_checkbox.value
return images
end
local function store(storage, image, format, filename, number, total, high_quality, extra_data)
if extra_data.strip_gps then
local command = string.format("exiftool -gps:all= -overwrite_original '%s'", filename)
os.execute(command)
end
if image.is_raw then
local original_path = image.path .. "/" .. image.filename
local raw_filename = original_path
if extra_data.strip_gps then
local tmpfile = os.tmpname()
local command = string.format("exiftool -gps:all= -o '%s' '%s'", tmpfile, original_path)
os.execute(command)
table.insert(extra_data.cleanup_files, tmpfile)
raw_filename = tmpfile
end
table.insert(extra_data.exported_files, filename .. ":" .. raw_filename)
else
table.insert(extra_data.exported_files, filename)
end
end
local function finalize(storage, image_table, extra_data)
local files_arg = table.concat(extra_data.exported_files, " ")
local command = string.format(
"darktable-publish --title '%s' --slug '%s' %s",
extra_data.title, extra_data.slug, files_arg
)
if extra_data.keywords ~= "" then
command = command .. string.format(" --keywords %s", extra_data.keywords)
end
-- Ignore that I use an external tool (written in JavaScript god forbid)
-- I am _not_ doing JSON generation and web requests in Lua
local result = os.capture(command)
if result and result:match("^http") then
dt.print("Post published: " .. result)
else
dt.print("Failed to publish post.")
end
local command = string.format("xdg-open %s", result)
os.execute(command)
for _, tmpfile in ipairs(extra_data.cleanup_files) do
os.remove(tmpfile)
end
end
local function supported(storage, format)
return true
end
dt.register_storage(
"ghost_publish",
"publish to Ghost CMS",
store,
finalize,
supported,
initialize,
widget
)

View File

@@ -0,0 +1,110 @@
import { sign } from "jsonwebtoken";
import { file } from "bun";
const getAdminApiKey = async () => {
const keyPath = process.env.GHOST_ADMIN_API_KEY_PATH;
if (!keyPath) {
throw new Error(
"Environment variable GHOST_ADMIN_API_KEY_PATH is not set.",
);
}
const keyFile = file(keyPath);
if (!(await keyFile.exists())) {
throw new Error(`Key file not found at path: ${keyPath}`);
}
return await keyFile.text();
};
const getEndpoint = () => {
const endpoint = process.env.GHOST_URL;
if (!endpoint) {
throw new Error("Environment variable GHOST_URL is not set.");
}
return endpoint;
};
const createJwt = (key: string) => {
const [id, secret] = key.split(":");
if (!id || !secret) {
throw new Error("Invalid API key format. Expected format: {id}:{secret}");
}
return sign({}, Buffer.from(secret, "hex"), {
keyid: id,
algorithm: "HS256",
expiresIn: "5m",
audience: `/admin/`,
});
};
const upload = async (
slug: string,
path: string,
type: string | undefined,
): Promise<any> => {
const endpoint = getEndpoint();
const fullEndpoint = `${endpoint}${slug}`;
const key = await getAdminApiKey();
const token = createJwt(key);
const f = Bun.file(path, { type });
const formData = new FormData();
formData.append("file", f);
const response = await fetch(fullEndpoint, {
method: "POST",
headers: {
Authorization: `Ghost ${token}`,
},
body: formData,
});
if (!response.ok) {
throw new Error(
`Failed to upload to ${fullEndpoint}: ${response.status} ${response.statusText}`,
);
}
return await response.json();
};
export const uploadImage = async (imagePath: string): Promise<string> => {
const slug = `/ghost/api/admin/images/upload`;
return (await upload(slug, imagePath, "image/jpeg")).images[0].url;
};
export const uploadFile = async (filePath: string): Promise<string> => {
const slug = `/ghost/api/admin/files/upload`;
return (await upload(slug, filePath, undefined)).files[0].url;
};
export const uploadPost = async (post: any): Promise<string> => {
const endpoint = getEndpoint();
const fullEndpoint = `${endpoint}/ghost/api/admin/posts`;
const key = await getAdminApiKey();
const token = createJwt(key);
const response = await fetch(fullEndpoint, {
method: "POST",
headers: {
Authorization: `Ghost ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
posts: [post],
}),
});
if (!response.ok) {
throw new Error(
`Failed to upload to ${fullEndpoint}: ${response.status} ${response.statusText}`,
);
}
return (await response.json()).posts[0].url;
};

View File

@@ -0,0 +1,48 @@
import { exiftool } from "exiftool-vendored";
import type { FileInfo } from "./files";
export interface ShootingConditions {
make: string;
model: string;
lensMake: string;
lensModel: string;
focalLength: string;
focalLength35: string;
shutterSpeed: string;
fStop: string;
iso: string;
timestamp: string;
}
export const extractShootingConditions = async (
fileInfo: FileInfo,
): Promise<ShootingConditions> => {
const path = fileInfo.rawPath ?? fileInfo.jpegPath;
try {
const exifData = await exiftool.read(path);
return {
make: exifData.Make ?? "Unknown",
model: exifData.Model ?? "Unknown",
lensMake: exifData.LensMake ?? "Unknown",
lensModel: exifData.LensModel ?? "Unknown",
focalLength: exifData.FocalLength ?? "Unknown",
focalLength35: exifData.FocalLengthIn35mmFormat ?? "Unknown",
shutterSpeed: exifData.ExposureTime ?? "Unknown",
fStop: exifData.FNumber?.toString() ?? "Unknown",
iso: exifData.ISO?.toString() ?? "Unknown",
timestamp: new Date(
(exifData.CreateDate?.toString() as string).replace(/\./g, ":"),
).toISOString(),
};
} catch (error: any) {
throw new Error(
`Failed to extract EXIF data from ${path}: ${error.message}`,
);
}
};
export const createImageCaption = (exif: ShootingConditions) => {
return `${exif.make} ${exif.model}, ${exif.lensMake} ${exif.lensModel} @ ${exif.focalLength} (${exif.focalLength35}), ${exif.shutterSpeed} s, f/${exif.fStop}, ISO ${exif.iso}`;
};

View File

@@ -0,0 +1,66 @@
import { basename, extname } from "path";
export interface FileInfo {
jpegPath: string;
jpegSize: number;
rawPath?: string;
rawSize?: number;
}
export const getBasenameWithoutExtension = (path: string): string => {
const base = basename(path);
const extension = extname(path);
return base.slice(0, -extension.length);
};
export const getBasenameWithExtension = (path: string): string => {
return basename(path);
};
export const prepareFiles = async (files: string[]): Promise<FileInfo[]> => {
if (files.length > 10) {
throw new Error("Up to 10 files are allowed at a time.");
}
const parsedFiles: FileInfo[] = [];
for (const pair of files) {
const parts = pair.split(/(?<!\\):/);
const jpegPath = parts[0].replace(/\\:/g, ":");
const rawPath = parts[1]?.replace(/\\:/g, ":");
const jpegFile = Bun.file(jpegPath);
if (!(await jpegFile.exists())) {
throw new Error(`JPEG file not found: ${jpegPath}`);
}
const jpegSize = jpegFile.size;
if (!rawPath) {
parsedFiles.push({
jpegPath,
jpegSize,
rawPath: undefined,
rawSize: undefined,
});
continue;
}
const rawFile = Bun.file(rawPath);
if (!(await rawFile.exists())) {
throw new Error(`RAW file not found: ${rawPath}`);
}
const rawSize = rawFile.size;
parsedFiles.push({
jpegPath,
jpegSize,
rawPath: rawPath,
rawSize: rawSize,
});
}
return parsedFiles;
};

View File

@@ -0,0 +1,115 @@
import { Command } from "commander";
import { createFileNode, createImageNode, createHeadingNode } from "./lexical";
import {
extractShootingConditions,
createImageCaption,
} from "./exif";
import { uploadFile, uploadImage, uploadPost } from "./api";
import { getBasenameWithExtension, prepareFiles } from "./files";
new Command()
.name("darktable-publish")
.description("Publish files to GHOST CMS with optional metadata.")
.option("-t, --title [string]", "Specify the title")
.option("-s, --slug [string]", "Specify the slug")
.option("-k, --keywords [string...]", "Specify blog post keywords (tags)")
.argument("<files...>", "Files to process")
.action(async (files, options) => {
if (!options.title) {
throw new Error("Please specify a title.");
}
if (!options.slug) {
throw new Error("Please specify a slug.");
}
const parsedFiles = await prepareFiles(files);
const [
shootingConditions,
uploadedJpegImages,
uploadedJpegFiles,
uploadedRawFiles,
] = await Promise.all([
Promise.all(parsedFiles.map(extractShootingConditions)),
Promise.all(parsedFiles.map((f) => uploadImage(f.jpegPath))),
Promise.all(parsedFiles.map((f) => uploadFile(f.jpegPath))),
Promise.all(
parsedFiles.map((f) =>
f.rawPath ? uploadFile(f.rawPath) : Promise.resolve(undefined),
),
),
]);
const aggregatedFiles = parsedFiles.map((file, index) => ({
...file,
shootingConditions: shootingConditions[index],
uploadedJpegImage: uploadedJpegImages[index],
uploadedJpegFile: uploadedJpegFiles[index],
uploadedRawFile: uploadedRawFiles[index],
}));
const result: any = {
root: {
children: [],
direction: "ltr",
format: "",
indent: 0,
type: "root",
version: 1,
},
};
if (aggregatedFiles.length > 1) {
aggregatedFiles.slice(1).forEach((file) =>
result.root.children.push(
createImageNode({
src: file.uploadedJpegImage,
caption: createImageCaption(file.shootingConditions),
}),
),
);
}
result.root.children.push(createHeadingNode("Downloads", "h2"));
aggregatedFiles.forEach((file) => {
result.root.children.push(
createFileNode({
src: file.uploadedJpegFile,
name: getBasenameWithExtension(file.jpegPath),
size: file.jpegSize,
}),
);
if (file.uploadedRawFile && file.rawPath && file.rawSize) {
result.root.children.push(
createFileNode({
src: file.uploadedRawFile,
name: getBasenameWithExtension(file.rawPath),
size: file.rawSize,
}),
);
}
});
const post = {
title: options.title,
slug: options.slug,
lexical: JSON.stringify(result),
feature_image: aggregatedFiles[0].uploadedJpegImage,
feature_image_caption: createImageCaption(
aggregatedFiles[0].shootingConditions,
),
status: "published",
visibility: "public",
tags: options.keywords,
published_at: aggregatedFiles[0].shootingConditions.timestamp,
};
const url = await uploadPost(post);
console.log(url);
process.exit(0);
})
.parse();

View File

@@ -0,0 +1,50 @@
export const createTextNode = (text: string) => ({
detail: 0,
format: 0,
mode: "normal",
style: "",
text,
type: "extended-text",
version: 1,
});
export const createHeadingNode = (text: string, level: string) => ({
children: [createTextNode(text)],
direction: "ltr",
format: "",
indent: 0,
type: "extended-heading",
version: 1,
tag: level,
});
export interface ImageInput {
src: string;
caption: string;
}
export const createImageNode = (image: ImageInput) => {
return {
type: "image",
version: 1,
cardWidth: "regular",
...image,
};
};
export interface FileInput {
src: string;
name: string;
size: number;
}
export const createFileNode = (file: FileInput) => {
return {
type: "file",
src: file.src,
fileTitle: file.name,
fileName: file.name,
fileCaption: "",
fileSize: file.size,
};
};

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
// Enable latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

View File

@@ -12,22 +12,6 @@ let
hmConfig = config.home-manager.users.${user};
in
{
# FIXME: https://github.com/lassekongo83/adw-gtk3/issues/267
nixpkgs.overlays = [
(final: prev: {
adw-gtk3 = prev.adw-gtk3.overrideAttrs (oldAttrs: rec {
pname = "adw-gtk3";
version = "5.3";
src = pkgs.fetchFromGitHub {
owner = "lassekongo83";
repo = pname;
rev = "v${version}";
sha256 = "sha256-DpJLX9PJX1Q8dDOx7YOXQzgNECsKp5uGiCVTX6iSlbI=";
};
});
})
];
home-manager.users.${user} = {
gtk = {
enable = true;

View File

@@ -114,6 +114,7 @@ in
imports = [
./langs/c
./langs/lua
./langs/nix
./langs/python
./langs/svelte

View File

@@ -0,0 +1,9 @@
{ pkgs, ... }:
{
programs.vscode.extensions =
with pkgs;
with vscode-extensions;
[
sumneko.lua
];
}