Skip to content

Commit 187ff18

Browse files
lividclaude
andcommitted
Match the app's attachment pipeline in pn disk mode
- Classify the primary video/audio attachment by uniform type, like the app's AttachmentType.from, with video taking precedence over audio, replacing the extension allowlists that hardcoded webm as video and missed formats such as mkv - Convert HEIC attachments to JPEG named <basename>.jpg - Strip the GPS dictionary from JPEG attachments; unlike the app's removeGPSInfo, the rewrite preserves the encoded image data and the remaining metadata instead of re-encoding - Conversion or stripping failures fall back to a plain copy - Test covers uniform-type classification, real GPS EXIF removal, and an actual HEIC-to-JPEG conversion, skipping when HEIC encoding is unavailable Closes the remaining items from #466. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2b8b669 commit 187ff18

4 files changed

Lines changed: 166 additions & 7 deletions

File tree

Planet/versioning.xcconfig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
CURRENT_PROJECT_VERSION = 2822
1+
CURRENT_PROJECT_VERSION = 2823

PlanetCLI/PNDiskStore.swift

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import AppKit
22
import Foundation
3+
import ImageIO
4+
import UniformTypeIdentifiers
35

46
struct PNLibraryDoctorResult: Codable {
57
let libraryPath: String
@@ -460,9 +462,7 @@ final class PNDiskStore {
460462
}
461463
var names = replace ? [] : existing
462464
for attachment in attachments {
463-
let name = attachment.lastPathComponent
464-
let target = publicBase.appendingPathComponent(name, isDirectory: false)
465-
try copyReplacing(attachment, to: target)
465+
let name = try copyAttachment(attachment, into: publicBase)
466466
if !names.contains(name) {
467467
names.append(name)
468468
}
@@ -472,6 +472,59 @@ final class PNDiskStore {
472472
return (names, video, audio)
473473
}
474474

475+
/// Copy one attachment into the public folder, matching the app's
476+
/// attachment pipeline: HEIC images become `<basename>.jpg` and JPEG
477+
/// images are rewritten without GPS metadata. Falls back to a plain
478+
/// copy when conversion or stripping fails.
479+
private func copyAttachment(_ source: URL, into publicBase: URL) throws -> String {
480+
let ext = source.pathExtension.lowercased()
481+
if ext == "heic", let jpeg = jpegData(fromImageAt: source) {
482+
let name = source.deletingPathExtension().lastPathComponent + ".jpg"
483+
try jpeg.write(to: publicBase.appendingPathComponent(name, isDirectory: false), options: .atomic)
484+
return name
485+
}
486+
let name = source.lastPathComponent
487+
let target = publicBase.appendingPathComponent(name, isDirectory: false)
488+
if ext == "jpg" || ext == "jpeg", let stripped = gpsStrippedImageData(fromImageAt: source) {
489+
try stripped.write(to: target, options: .atomic)
490+
return name
491+
}
492+
try copyReplacing(source, to: target)
493+
return name
494+
}
495+
496+
private func jpegData(fromImageAt url: URL) -> Data? {
497+
guard let image = NSImage(contentsOf: url),
498+
let tiff = image.tiffRepresentation,
499+
let bitmap = NSBitmapImageRep(data: tiff)
500+
else {
501+
return nil
502+
}
503+
return bitmap.representation(using: .jpeg, properties: [:])
504+
}
505+
506+
/// Rewrite an image without its GPS dictionary, preserving the encoded
507+
/// image data and the remaining metadata.
508+
private func gpsStrippedImageData(fromImageAt url: URL) -> Data? {
509+
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
510+
let type = CGImageSourceGetType(source)
511+
else {
512+
return nil
513+
}
514+
let count = CGImageSourceGetCount(source)
515+
guard count > 0 else { return nil }
516+
let output = NSMutableData()
517+
guard let destination = CGImageDestinationCreateWithData(output, type, count, nil) else {
518+
return nil
519+
}
520+
let removeGPS = [kCGImagePropertyGPSDictionary: kCFNull as Any] as CFDictionary
521+
for index in 0..<count {
522+
CGImageDestinationAddImageFromSource(destination, source, index, removeGPS)
523+
}
524+
guard CGImageDestinationFinalize(destination) else { return nil }
525+
return output as Data
526+
}
527+
475528
private func publicArticleRecord(from article: PNArticleRecord, planet: PNPlanetRecord) -> PNPublicArticleRecord {
476529
PNPublicArticleRecord(
477530
articleType: article.articleType ?? 0,
@@ -742,11 +795,21 @@ final class PNDiskStore {
742795
return String(text.prefix(180)).pnTrimmed
743796
}
744797

798+
// Classify by uniform type like the app's AttachmentType.from, with video
799+
// taking precedence over audio for types that conform to both.
745800
private func isVideo(_ name: String) -> Bool {
746-
["mp4", "m4v", "mov", "avi", "mpeg", "mpg", "webm"].contains((name as NSString).pathExtension.lowercased())
801+
guard let type = contentType(forName: name) else { return false }
802+
return type.conforms(to: .movie) || type.conforms(to: .video)
747803
}
748804

749805
private func isAudio(_ name: String) -> Bool {
750-
["aac", "mp3", "m4a", "ogg", "wav", "webm"].contains((name as NSString).pathExtension.lowercased())
806+
guard let type = contentType(forName: name), !isVideo(name) else { return false }
807+
return type.conforms(to: .audio)
808+
}
809+
810+
private func contentType(forName name: String) -> UTType? {
811+
let ext = (name as NSString).pathExtension
812+
guard !ext.isEmpty else { return nil }
813+
return UTType(filenameExtension: ext)
751814
}
752815
}

PlanetCLITests/PNDiskStoreTests.swift

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import AppKit
2+
import ImageIO
3+
import UniformTypeIdentifiers
24
import XCTest
35

46
final class PNDiskStoreTests: XCTestCase {
@@ -189,6 +191,42 @@ final class PNDiskStoreTests: XCTestCase {
189191
XCTAssertEqual(publicArticle?.attachments, [])
190192
}
191193

194+
func testDiskAttachmentsMatchAppPipeline() throws {
195+
let sandbox = try PNTestSandbox()
196+
defer { sandbox.cleanup() }
197+
198+
let store = PNDiskStore(root: sandbox.libraryURL)
199+
let planet = try store.createPlanet(name: "Pipeline Planet", about: "", template: nil, avatar: nil)
200+
defer { _ = try? store.deletePlanet(planet) }
201+
202+
// Primary video/audio classified by uniform type, video first.
203+
let movie = try sandbox.makeTextFixture(name: "clip.mov", contents: "fake")
204+
let song = try sandbox.makeTextFixture(name: "song.mp3", contents: "fake")
205+
let blob = try sandbox.makeTextFixture(name: "data.bin", contents: "fake")
206+
var article = try store.createArticle(planet: planet, title: "Media", content: "x", date: nil, attachments: [movie, song, blob])
207+
XCTAssertEqual(article.videoFilename, "clip.mov")
208+
XCTAssertEqual(article.audioFilename, "song.mp3")
209+
210+
// GPS metadata is stripped from JPEG attachments.
211+
let jpegWithGPS = try makeJPEGWithGPSFixture(named: "gps.jpg", in: sandbox)
212+
XCTAssertNotNil(gpsDictionary(at: jpegWithGPS), "Fixture should carry GPS metadata.")
213+
article = try store.addAttachments(planet: planet, article: article, attachments: [jpegWithGPS])
214+
XCTAssertTrue(article.attachments?.contains("gps.jpg") == true)
215+
let publicJPEG = store.articlePublicPath(article, in: planet).appendingPathComponent("gps.jpg", isDirectory: false)
216+
XCTAssertNil(gpsDictionary(at: publicJPEG))
217+
XCTAssertNotNil(NSBitmapImageRep(data: try Data(contentsOf: publicJPEG)), "Stripped JPEG should remain a valid image.")
218+
219+
// HEIC attachments are converted to <basename>.jpg.
220+
guard let heic = makeHEICFixture(named: "photo.heic", in: sandbox) else {
221+
throw XCTSkip("HEIC encoding is unavailable on this machine.")
222+
}
223+
article = try store.addAttachments(planet: planet, article: article, attachments: [heic])
224+
XCTAssertTrue(article.attachments?.contains("photo.jpg") == true)
225+
XCTAssertFalse(article.attachments?.contains("photo.heic") == true)
226+
let converted = store.articlePublicPath(article, in: planet).appendingPathComponent("photo.jpg", isDirectory: false)
227+
XCTAssertEqual(Array(try Data(contentsOf: converted).prefix(2)), [0xFF, 0xD8], "Converted file should be a JPEG.")
228+
}
229+
192230
func testPartialUUIDSelectorsResolveWithExactMatchPrecedence() throws {
193231
let sandbox = try PNTestSandbox()
194232
defer { sandbox.cleanup() }
@@ -301,6 +339,64 @@ final class PNDiskStoreTests: XCTestCase {
301339
XCTAssertEqual(parsed.arguments, ["planet", "list"])
302340
}
303341

342+
private func makeJPEGWithGPSFixture(named name: String, in sandbox: PNTestSandbox) throws -> URL {
343+
let base = try sandbox.makeImageFixture(name: "base-\(name)", width: 40, height: 40)
344+
let data = try Data(contentsOf: base)
345+
guard let source = CGImageSourceCreateWithData(data as CFData, nil),
346+
let type = CGImageSourceGetType(source)
347+
else {
348+
throw PNError.diskError("Unable to read JPEG fixture source.")
349+
}
350+
let url = sandbox.root.appendingPathComponent(name, isDirectory: false)
351+
guard let destination = CGImageDestinationCreateWithURL(url as CFURL, type, 1, nil) else {
352+
throw PNError.diskError("Unable to create JPEG fixture destination.")
353+
}
354+
let gps: [CFString: Any] = [
355+
kCGImagePropertyGPSLatitude: 37.7749,
356+
kCGImagePropertyGPSLongitude: 122.4194,
357+
]
358+
CGImageDestinationAddImageFromSource(destination, source, 0, [kCGImagePropertyGPSDictionary: gps] as CFDictionary)
359+
guard CGImageDestinationFinalize(destination) else {
360+
throw PNError.diskError("Unable to write JPEG fixture with GPS metadata.")
361+
}
362+
return url
363+
}
364+
365+
private func gpsDictionary(at url: URL) -> [CFString: Any]? {
366+
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
367+
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any]
368+
else {
369+
return nil
370+
}
371+
return properties[kCGImagePropertyGPSDictionary] as? [CFString: Any]
372+
}
373+
374+
private func makeHEICFixture(named name: String, in sandbox: PNTestSandbox) -> URL? {
375+
guard let bitmap = NSBitmapImageRep(
376+
bitmapDataPlanes: nil,
377+
pixelsWide: 40,
378+
pixelsHigh: 40,
379+
bitsPerSample: 8,
380+
samplesPerPixel: 4,
381+
hasAlpha: true,
382+
isPlanar: false,
383+
colorSpaceName: .deviceRGB,
384+
bytesPerRow: 0,
385+
bitsPerPixel: 0
386+
), let cgImage = bitmap.cgImage else {
387+
return nil
388+
}
389+
let url = sandbox.root.appendingPathComponent(name, isDirectory: false)
390+
guard let destination = CGImageDestinationCreateWithURL(url as CFURL, UTType.heic.identifier as CFString, 1, nil) else {
391+
return nil
392+
}
393+
CGImageDestinationAddImage(destination, cgImage, nil)
394+
guard CGImageDestinationFinalize(destination) else {
395+
return nil
396+
}
397+
return url
398+
}
399+
304400
private func writePlanetFixture(_ planet: PNPlanetRecord, in libraryURL: URL) throws {
305401
let directory = libraryURL
306402
.appendingPathComponent("My", isDirectory: true)

Technotes/CLI.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ Human-readable tables are the default. `--json` is the machine-readable mode.
187187
- `article attachment add <path>...` appends files, like `update --attachment`.
188188
- `article attachment delete <name>...` removes named attachments one by one.
189189

190-
In API mode, append/replace map to the `attachmentMode=append|replace` query parameter on the article-modify route, and the `attachment` subcommands map to the `/attachments` sub-routes. Disk mode performs the equivalent file operations directly. Disk mode does not perform the app's HEIC-to-JPEG conversion or GPS stripping, and classifies the primary video/audio file by extension rather than by uniform type.
190+
In API mode, append/replace map to the `attachmentMode=append|replace` query parameter on the article-modify route, and the `attachment` subcommands map to the `/attachments` sub-routes. Disk mode performs the equivalent file operations directly and matches the app's attachment pipeline: HEIC attachments are converted to JPEG named `<basename>.jpg`, GPS metadata is stripped from JPEG attachments without re-encoding the image, and the primary video/audio file is classified by uniform type with video taking precedence.
191191

192192
In API mode the app serves reads and planet deletion for archived planets, but rejects content mutations and publish with HTTP 400 because archived planets are excluded from publishing. Disk mode does not enforce this and will mutate archived planet files when asked.
193193

0 commit comments

Comments
 (0)