Build first working version of IJSS for Tumblr

- Folder browser: choose an IJSS folder, create tag folders, add images
  by drag-and-drop or Open panel, with thumbnails and Finder sync
- Duplicate detection: SHA-256 fingerprints checked against a SwiftData
  (SQLite) history of posted images
- Tumblr login via OAuth 2 with a localhost redirect; tokens kept in the
  Keychain and refreshed automatically
- Posting to the Tumblr queue with folder names as tags, mixed or
  folder-by-folder order, a courtesy delay, progress, and a summary;
  posted images are archived to _Posted/<Tag>/
- Settings for the folder, Tumblr account, posting options, and clearing
  posted history (archive goes to the Trash)
- TODO.md tracking open items

Tumblr app credentials live in TumblrCredentials.plist, which is
excluded from Git.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 16:52:06 -04:00
co-authored by Claude Opus 5.5
parent b549c15d77
commit d5cd350946
29 changed files with 2788 additions and 0 deletions
+4
View File
@@ -30,3 +30,7 @@ Temporary Items
## User settings ## User settings
xcuserdata/ xcuserdata/
# ---> IJSS
## Tumblr app credentials (contains the client secret)
TumblrCredentials.plist
+350
View File
@@ -0,0 +1,350 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 90;
objects = {
/* Begin PBXFileReference section */
000000000000000000000120 /* IJSS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = IJSS.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
000000000000000000000010 /* IJSS */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = IJSS;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
000000000000000130000000 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
files = (
);
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
000000000000000000000001 = {
isa = PBXGroup;
children = (
000000000000000000000010 /* IJSS */,
000000000000000000000020 /* Products */,
);
sourceTree = "<group>";
};
000000000000000000000020 /* Products */ = {
isa = PBXGroup;
children = (
000000000000000000000120 /* IJSS.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
000000000000000100000000 /* IJSS */ = {
isa = PBXNativeTarget;
buildConfigurationList = 000000000000000110000000 /* Build configuration list for PBXNativeTarget "IJSS" */;
buildPhases = (
000000000000000120000000 /* Sources */,
000000000000000130000000 /* Frameworks */,
000000000000000140000000 /* Resources */,
);
buildRules = (
);
fileSystemSynchronizedGroups = (
000000000000000000000010 /* IJSS */,
);
name = IJSS;
productName = MyApp;
productReference = 000000000000000000000120 /* IJSS.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
000000000000000000000000 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2630;
LastUpgradeCheck = 2700;
TargetAttributes = {
000000000000000100000000 = {
CreatedOnToolsVersion = 26.3;
};
};
};
buildConfigurationList = 000000000000000010000000 /* Build configuration list for PBXProject "IJSS" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 000000000000000000000001;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 90;
productRefGroup = 000000000000000000000020 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
000000000000000100000000 /* IJSS */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
000000000000000140000000 /* Resources */ = {
isa = PBXResourcesBuildPhase;
files = (
);
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
000000000000000120000000 /* Sources */ = {
isa = PBXSourcesBuildPhase;
files = (
);
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
000000000000000011000000 /* Debug configuration for PBXProject "IJSS" */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
DRIVERKIT_DEPLOYMENT_TARGET = 27.0;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 27.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PROJECT_UNIQUE_VALUE = E2ZZLOSE;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
TVOS_DEPLOYMENT_TARGET = 27.0;
WATCHOS_DEPLOYMENT_TARGET = 27.0;
XROS_DEPLOYMENT_TARGET = 27.0;
};
name = Debug;
};
000000000000000012000000 /* Release configuration for PBXProject "IJSS" */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DRIVERKIT_DEPLOYMENT_TARGET = 27.0;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 27.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PROJECT_UNIQUE_VALUE = E2ZZLOSE;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
TVOS_DEPLOYMENT_TARGET = 27.0;
WATCHOS_DEPLOYMENT_TARGET = 27.0;
XROS_DEPLOYMENT_TARGET = 27.0;
};
name = Release;
};
000000000000000111000000 /* Debug configuration for PBXNativeTarget "IJSS" */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
ENABLE_APP_SANDBOX = YES;
ENABLE_INCOMING_NETWORK_CONNECTIONS = YES;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readwrite;
GENERATE_INFOPLIST_FILE = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.DanielMesser.IJSS;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = macosx;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
};
name = Debug;
};
000000000000000112000000 /* Release configuration for PBXNativeTarget "IJSS" */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
ENABLE_APP_SANDBOX = YES;
ENABLE_INCOMING_NETWORK_CONNECTIONS = YES;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readwrite;
GENERATE_INFOPLIST_FILE = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.DanielMesser.IJSS;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = macosx;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
000000000000000010000000 /* Build configuration list for PBXProject "IJSS" */ = {
isa = XCConfigurationList;
buildConfigurations = (
000000000000000011000000 /* Debug configuration for PBXProject "IJSS" */,
000000000000000012000000 /* Release configuration for PBXProject "IJSS" */,
);
defaultConfigurationName = Release;
};
000000000000000110000000 /* Build configuration list for PBXNativeTarget "IJSS" */ = {
isa = XCConfigurationList;
buildConfigurations = (
000000000000000111000000 /* Debug configuration for PBXNativeTarget "IJSS" */,
000000000000000112000000 /* Release configuration for PBXNativeTarget "IJSS" */,
);
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 000000000000000000000000 /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+236
View File
@@ -0,0 +1,236 @@
import AppKit
import SwiftData
import SwiftUI
/// The main window. Shows first-time setup until an IJSS folder is chosen,
/// then the folder sidebar and image grid.
struct ContentView: View {
@Environment(ImageLibrary.self) private var library
@Environment(TumblrAccount.self) private var account
@Environment(\.appearsActive) private var appearsActive
var body: some View {
Group {
if library.rootURL == nil {
WelcomeView()
} else {
LibraryView()
}
}
.frame(minWidth: 720, minHeight: 480)
.alert(
"Something Went Wrong",
isPresented: Binding(
get: { library.errorMessage != nil },
set: { if !$0 { library.errorMessage = nil } }
)
) {
Button("OK") {}
} message: {
Text(library.errorMessage ?? "")
}
// Tumblr problems are shown in whichever window is in front (this one or Settings),
// so the same message doesn't pop up twice.
.alert(
"Tumblr",
isPresented: Binding(
get: { appearsActive && account.errorMessage != nil },
set: { if !$0 { account.errorMessage = nil } }
)
) {
Button("OK") {}
} message: {
Text(account.errorMessage ?? "")
}
}
}
/// Sidebar of tag folders on the left, images in the selected folder on the right.
struct LibraryView: View {
@Environment(ImageLibrary.self) private var library
@Environment(ImageScanner.self) private var scanner
@Environment(TumblrAccount.self) private var account
@Environment(TumblrPoster.self) private var poster
@Environment(\.modelContext) private var modelContext
@AppStorage(PostingSettings.orderKey) private var postingOrder = PostingSettings.defaultOrder
@AppStorage(PostingSettings.delayKey) private var postingDelay = PostingSettings.defaultDelay
@State private var selectedTagID: TagFolder.ID?
@State private var scanSummary: ScanSummary?
@State private var pendingBatch: PendingBatch?
/// A batch waiting for the user to confirm it in the "Post to Queue?" dialog.
private struct PendingBatch {
let items: [QueuedImage]
let blogName: String
/// Explains why fewer images than are ready will be posted, if so.
let limitNote: String?
}
var body: some View {
NavigationSplitView {
SidebarView(selection: $selectedTagID)
.navigationSplitViewColumnWidth(min: 180, ideal: 220)
} detail: {
if let tag = library.tag(withID: selectedTagID) {
ImageGridView(tag: tag)
} else {
ContentUnavailableView(
"Select a Folder",
systemImage: "sidebar.left",
description: Text("Choose a tag folder on the left to see its images.")
)
}
}
.toolbar {
ToolbarItemGroup {
if scanner.isScanning {
ProgressView()
.controlSize(.small)
}
Button("Scan Folders", systemImage: "arrow.clockwise") {
Task {
library.reload()
scanSummary = await scanner.scan(library.tags, context: modelContext)
}
}
.help("Check the folders for new images and duplicates")
Button("Show in Finder", systemImage: "folder") {
if let serviceURL = library.serviceURL {
NSWorkspace.shared.open(serviceURL)
}
}
}
ToolbarItem(placement: .primaryAction) {
Button("Post to Queue", systemImage: "paperplane") {
preparePost()
}
.disabled(!account.isConnected || account.selectedBlog == nil || poster.isPosting || scanner.isScanning)
.help(account.isConnected
? "Add all ready images to your Tumblr queue"
: "Connect to Tumblr first")
}
}
.safeAreaInset(edge: .bottom, spacing: 0) {
if poster.isPosting {
PostingProgressView()
}
}
.confirmationDialog(
"Post to Queue?",
isPresented: Binding(
get: { pendingBatch != nil },
set: { if !$0 { pendingBatch = nil } }
),
presenting: pendingBatch
) { batch in
Button("Post \(batch.items.count) \(batch.items.count == 1 ? "Image" : "Images")") {
poster.start(batch.items, blogName: batch.blogName, delaySeconds: postingDelay,
account: account, library: library, context: modelContext)
}
Button("Cancel", role: .cancel) {}
} message: { batch in
Text(confirmationMessage(for: batch))
}
.alert(
"Posting Finished",
isPresented: Binding(
get: { poster.report != nil },
set: { if !$0 { poster.report = nil } }
),
presenting: poster.report
) { _ in
Button("OK") {}
} message: { report in
Text(report.description)
}
// Pick up anything changed in Finder whenever the user switches back to IJSS.
.task {
for await _ in NotificationCenter.default.notifications(named: NSApplication.didBecomeActiveNotification) {
library.reload()
}
}
// Re-check images against the database whenever the folders' contents change.
// Unchanged files are cached, so this is quick after the first time.
.task(id: library.tags) {
await scanner.scan(library.tags, context: modelContext)
}
.alert(
"Scan Complete",
isPresented: Binding(
get: { scanSummary != nil },
set: { if !$0 { scanSummary = nil } }
),
presenting: scanSummary
) { _ in
Button("OK") {}
} message: { summary in
Text(summary.description)
}
}
// MARK: - Posting
/// Gathers the ready images, applies Tumblr's daily limit, and asks the user to confirm.
private func preparePost() {
guard let blog = account.selectedBlog else { return }
let ready = scanner.readyImages(in: library.tags, order: postingOrder)
guard !ready.isEmpty else {
scanSummary = ScanSummary() // Shows "no images" via the scan alert.
return
}
let remainingToday = max(PostingSettings.dailyPostLimit - postsMadeToday(), 0)
var limitNote: String?
if ready.count > remainingToday {
limitNote = "Tumblr allows \(PostingSettings.dailyPostLimit) posts a day. IJSS has posted \(PostingSettings.dailyPostLimit - remainingToday) today, so only \(remainingToday) of your \(ready.count) ready images will be posted now. Post the rest tomorrow."
}
guard remainingToday > 0 else {
library.errorMessage = limitNote
return
}
pendingBatch = PendingBatch(
items: Array(ready.prefix(remainingToday)),
blogName: blog.name,
limitNote: limitNote
)
}
private func confirmationMessage(for batch: PendingBatch) -> String {
let pauses = Duration.seconds(postingDelay * max(batch.items.count - 1, 0))
let estimate = pauses.formatted(.units(allowed: [.hours, .minutes, .seconds], width: .wide, maximumUnitCount: 2))
var message = "\(batch.items.count) images will be added to the end of \(batch.blogName)'s queue, tagged with their folder names. With a \(postingDelay)-second pause between posts, this will take about \(estimate)."
if let note = batch.limitNote {
message += "\n\n" + note
}
return message
}
/// How many posts IJSS has made today (not counting images marked as posted by hand).
private func postsMadeToday() -> Int {
let startOfDay = Calendar.current.startOfDay(for: .now)
let descriptor = FetchDescriptor<PostedImage>(
predicate: #Predicate { $0.postedAt >= startOfDay && $0.remotePostID != nil }
)
return (try? modelContext.fetchCount(descriptor)) ?? 0
}
}
extension ScanSummary: CustomStringConvertible {
var description: String {
guard total > 0 else { return "There are no images in your tag folders yet." }
var lines = ["\(ready) ready to post"]
if alreadyPosted > 0 { lines.append("\(alreadyPosted) already posted") }
if duplicates > 0 { lines.append("\(duplicates) duplicates of other images in the folders") }
if unreadable > 0 { lines.append("\(unreadable) couldn't be read") }
return lines.joined(separator: "\n")
}
}
#Preview {
ContentView()
.environment(ImageLibrary())
.environment(ImageScanner())
.environment(TumblrAccount())
.environment(TumblrPoster())
.modelContainer(for: PostedImage.self, inMemory: true)
}
+20
View File
@@ -0,0 +1,20 @@
import CryptoKit
import Foundation
/// Computes a SHA-256 fingerprint of a file's contents.
/// Two files with the same fingerprint are the same image, whatever their names.
enum FileHasher {
/// Reads the file in 1 MB chunks so large images don't need to fit in memory.
/// `@concurrent` runs this off the main thread, keeping the window responsive.
@concurrent nonisolated static func sha256(of url: URL) async throws -> String {
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
var hasher = SHA256()
while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty {
try Task.checkCancellation()
hasher.update(data: chunk)
}
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
}
}
+243
View File
@@ -0,0 +1,243 @@
import Foundation
import Observation
import UniformTypeIdentifiers
/// The app's view of the IJSS folder on disk.
///
/// Layout:
/// ```
/// IJSS/ <- the folder the user picks
/// Tumblr/ <- one folder per service
/// Cyberpunk/ <- one folder per tag; images inside get this tag
/// _Posted/ <- archive of posted images (ignored when listing tags)
/// ```
/// The disk is always the source of truth. Call `reload()` to pick up changes
/// made in Finder.
@Observable
final class ImageLibrary {
static let serviceFolderName = "Tumblr"
static let archiveFolderName = "_Posted"
/// The IJSS folder the user picked, or `nil` before first-time setup.
private(set) var rootURL: URL?
/// The tag folders inside the Tumblr folder, sorted by name.
private(set) var tags: [TagFolder] = []
/// Set when something goes wrong; the UI shows it in an alert.
var errorMessage: String?
var serviceURL: URL? {
rootURL?.appending(path: Self.serviceFolderName, directoryHint: .isDirectory)
}
private let fileManager = FileManager.default
init() {
restoreSavedLocation()
}
// MARK: - Choosing the IJSS folder
/// Uses a folder the user just picked in an Open panel, and remembers it for next time.
func useFolder(_ url: URL) {
stopAccessingRoot()
guard url.startAccessingSecurityScopedResource() else {
errorMessage = "IJSS couldn't get permission to use “\(url.lastPathComponent)”."
return
}
do {
try LibraryLocationStore.save(url)
rootURL = url
try createServiceFolderIfNeeded()
reload()
} catch {
errorMessage = error.localizedDescription
}
}
/// Forgets the current folder (files on disk are not touched) and returns to first-time setup.
func forgetFolder() {
stopAccessingRoot()
LibraryLocationStore.clear()
rootURL = nil
tags = []
}
private func restoreSavedLocation() {
guard let (url, isStale) = LibraryLocationStore.restore(),
url.startAccessingSecurityScopedResource() else { return }
if isStale {
try? LibraryLocationStore.save(url)
}
rootURL = url
try? createServiceFolderIfNeeded()
reload()
}
private func stopAccessingRoot() {
rootURL?.stopAccessingSecurityScopedResource()
}
private func createServiceFolderIfNeeded() throws {
guard let serviceURL else { return }
try fileManager.createDirectory(at: serviceURL, withIntermediateDirectories: true)
}
// MARK: - Reading folders
func tag(withID id: TagFolder.ID?) -> TagFolder? {
tags.first { $0.id == id }
}
/// Rescans the Tumblr folder for tag folders and the images inside them.
func reload() {
guard let serviceURL else {
tags = []
return
}
do {
tags = try subfolders(of: serviceURL)
.filter { $0.lastPathComponent != Self.archiveFolderName }
.map { TagFolder(url: $0, images: images(in: $0)) }
.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
} catch {
errorMessage = error.localizedDescription
}
}
private func subfolders(of url: URL) throws -> [URL] {
try fileManager.contentsOfDirectory(
at: url,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
)
.filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true }
}
private func images(in folder: URL) -> [ImageItem] {
let contents = (try? fileManager.contentsOfDirectory(
at: folder,
includingPropertiesForKeys: [.contentTypeKey],
options: [.skipsHiddenFiles]
)) ?? []
return contents
.filter(Self.isImage)
.map(ImageItem.init(url:))
.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
}
private nonisolated static func isImage(_ url: URL) -> Bool {
let type = (try? url.resourceValues(forKeys: [.contentTypeKey]).contentType)
?? UTType(filenameExtension: url.pathExtension)
return type?.conforms(to: .image) ?? false
}
// MARK: - Changing folders
/// Creates a new tag folder and returns its ID.
@discardableResult
func createTag(named rawName: String) -> TagFolder.ID? {
let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines)
guard let serviceURL else { return nil }
// Commas are blocked because Tumblr treats them as separators between tags.
guard !name.isEmpty, !name.hasPrefix("."), !name.hasPrefix("_"),
!name.contains("/"), !name.contains(":"), !name.contains(",") else {
errorMessage = "“\(name)” can't be used as a folder name. Avoid “/”, “:” and “,”, and don't start the name with “.” or “_”."
return nil
}
let url = serviceURL.appending(path: name, directoryHint: .isDirectory)
guard !fileManager.fileExists(atPath: url.path(percentEncoded: false)) else {
errorMessage = "A folder named “\(name)” already exists."
return nil
}
do {
try fileManager.createDirectory(at: url, withIntermediateDirectories: false)
reload()
return url
} catch {
errorMessage = error.localizedDescription
return nil
}
}
/// Copies image files into a tag folder. Non-image files are skipped, and
/// name clashes get a number added (“photo 2.jpg”) instead of overwriting.
func addImages(_ urls: [URL], to tag: TagFolder) {
var skipped: [String] = []
for source in urls {
// Files chosen in an Open panel need their access turned on while we copy them.
let didAccess = source.startAccessingSecurityScopedResource()
defer { if didAccess { source.stopAccessingSecurityScopedResource() } }
guard Self.isImage(source) else {
skipped.append(source.lastPathComponent)
continue
}
do {
try fileManager.copyItem(at: source, to: availableURL(for: source.lastPathComponent, in: tag.url))
} catch {
skipped.append(source.lastPathComponent)
}
}
reload()
if !skipped.isEmpty {
errorMessage = "These files weren't added because they aren't images or couldn't be copied:\n\(skipped.joined(separator: "\n"))"
}
}
/// The `Tumblr/_Posted` folder, where posted images are archived.
var archiveURL: URL? {
serviceURL?.appending(path: Self.archiveFolderName, directoryHint: .isDirectory)
}
/// Moves the whole `_Posted` archive to the Trash. Does nothing if there's no archive yet.
func trashArchive() throws {
guard let archiveURL, fileManager.fileExists(atPath: archiveURL.path(percentEncoded: false)) else { return }
try fileManager.trashItem(at: archiveURL, resultingItemURL: nil)
}
/// Total disk space used by a folder and everything in it, in bytes.
/// Runs off the main thread, since large archives can take a moment to add up.
@concurrent nonisolated static func diskUsage(of folder: URL) async -> Int64 {
addUpFileSizes(in: folder)
}
private nonisolated static func addUpFileSizes(in folder: URL) -> Int64 {
let keys: [URLResourceKey] = [.totalFileAllocatedSizeKey, .isRegularFileKey]
guard let enumerator = FileManager.default.enumerator(at: folder, includingPropertiesForKeys: keys) else {
return 0
}
var total: Int64 = 0
for case let url as URL in enumerator {
guard let values = try? url.resourceValues(forKeys: Set(keys)), values.isRegularFile == true else { continue }
total += Int64(values.totalFileAllocatedSize ?? 0)
}
return total
}
/// Moves a posted image into `Tumblr/_Posted/<Tag>/` and returns its new location.
/// Call `reload()` afterwards to update the folder list.
func archive(_ image: ImageItem, tagName: String) throws -> URL {
guard let serviceURL else { throw CocoaError(.fileNoSuchFile) }
let folder = serviceURL
.appending(path: Self.archiveFolderName, directoryHint: .isDirectory)
.appending(path: tagName, directoryHint: .isDirectory)
try fileManager.createDirectory(at: folder, withIntermediateDirectories: true)
let destination = availableURL(for: image.name, in: folder)
try fileManager.moveItem(at: image.url, to: destination)
return destination
}
/// Returns a file URL in `folder` that doesn't already exist, based on `fileName`.
private func availableURL(for fileName: String, in folder: URL) -> URL {
let base = (fileName as NSString).deletingPathExtension
let ext = (fileName as NSString).pathExtension
var candidate = folder.appending(path: fileName)
var counter = 2
while fileManager.fileExists(atPath: candidate.path(percentEncoded: false)) {
let numbered = ext.isEmpty ? "\(base) \(counter)" : "\(base) \(counter).\(ext)"
candidate = folder.appending(path: numbered)
counter += 1
}
return candidate
}
}
+181
View File
@@ -0,0 +1,181 @@
import Foundation
import Observation
import SwiftData
/// Where an image stands before posting.
enum ImageStatus: Equatable {
/// Still being fingerprinted.
case checking
/// New, and will be posted.
case ready
/// This exact image is already in the posted-images database.
case alreadyPosted(on: Date, tag: String)
/// The same image appears earlier in the queue (another folder, or a renamed copy).
case duplicate(of: URL)
/// The file couldn't be read.
case unreadable
}
/// An image that's ready to post, along with what's needed to post and record it.
struct QueuedImage: Identifiable {
let image: ImageItem
let tagName: String
let contentHash: String
var id: URL { image.url }
}
/// Counts from a full scan, shown to the user after they click Scan Folders.
struct ScanSummary {
var ready = 0
var alreadyPosted = 0
var duplicates = 0
var unreadable = 0
var total: Int { ready + alreadyPosted + duplicates + unreadable }
}
/// Fingerprints every image in the tag folders and checks them against the database.
@Observable
final class ImageScanner {
private(set) var statuses: [URL: ImageStatus] = [:]
var isScanning: Bool { activeScans > 0 }
private var activeScans = 0
private var hashesByURL: [URL: String] = [:]
/// Fingerprints are cached by file size and modification date, so unchanged
/// files aren't re-read every time the folders are rescanned.
@ObservationIgnored private var hashCache: [URL: CachedHash] = [:]
private struct CachedHash {
let size: Int?
let modified: Date?
let hash: String
}
func status(for image: ImageItem) -> ImageStatus {
statuses[image.url] ?? .checking
}
/// The images that are ready to post, arranged in the chosen posting order.
func readyImages(in tags: [TagFolder], order: PostingOrder) -> [QueuedImage] {
let perFolder: [[QueuedImage]] = tags.map { tag in
tag.images.compactMap { image in
guard status(for: image) == .ready, let hash = hashesByURL[image.url] else { return nil }
return QueuedImage(image: image, tagName: tag.name, contentHash: hash)
}
}
switch order {
case .folderByFolder:
return perFolder.flatMap { $0 }
case .mixed:
// Take one image from each folder in turn: A1, B1, C1, A2, B2, …
var result: [QueuedImage] = []
let longest = perFolder.map(\.count).max() ?? 0
for index in 0..<longest {
for folder in perFolder where index < folder.count {
result.append(folder[index])
}
}
return result
}
}
// MARK: - Scanning
/// Scans all images in `tags`. Returns `nil` if the scan was cancelled.
@discardableResult
func scan(_ tags: [TagFolder], context: ModelContext) async -> ScanSummary? {
activeScans += 1
defer { activeScans -= 1 }
// 1. Fingerprint every image, in the order they'd be posted.
var found: [(url: URL, hash: String?)] = []
for tag in tags {
for image in tag.images {
let hash = await hash(for: image.url)
if Task.isCancelled { return nil }
found.append((image.url, hash))
}
}
// 2. Ask the database which of those fingerprints have already been posted.
let hashes = Array(Set(found.compactMap(\.hash)))
let descriptor = FetchDescriptor<PostedImage>(
predicate: #Predicate { hashes.contains($0.contentHash) }
)
let postedRecords = (try? context.fetch(descriptor)) ?? []
let postedByHash = Dictionary(
postedRecords.map { ($0.contentHash, $0) },
uniquingKeysWith: { first, _ in first }
)
// 3. Decide each image's status. The first copy of a new image is "ready";
// any later copies are duplicates of it.
var newStatuses: [URL: ImageStatus] = [:]
var firstURLForHash: [String: URL] = [:]
var summary = ScanSummary()
for (url, hash) in found {
guard let hash else {
newStatuses[url] = .unreadable
summary.unreadable += 1
continue
}
if let record = postedByHash[hash] {
newStatuses[url] = .alreadyPosted(on: record.postedAt, tag: record.tag)
summary.alreadyPosted += 1
} else if let original = firstURLForHash[hash] {
newStatuses[url] = .duplicate(of: original)
summary.duplicates += 1
} else {
firstURLForHash[hash] = url
newStatuses[url] = .ready
summary.ready += 1
}
}
statuses = newStatuses
hashesByURL = Dictionary(
found.compactMap { item in item.hash.map { (item.url, $0) } },
uniquingKeysWith: { first, _ in first }
)
return summary
}
private func hash(for url: URL) async -> String? {
let values = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey])
if let cached = hashCache[url],
cached.size == values?.fileSize,
cached.modified == values?.contentModificationDate {
return cached.hash
}
guard let hash = try? await FileHasher.sha256(of: url) else { return nil }
hashCache[url] = CachedHash(size: values?.fileSize, modified: values?.contentModificationDate, hash: hash)
return hash
}
// MARK: - Manually editing posted history
/// Records an image as posted without posting it, e.g. if it was posted by hand.
func markAsPosted(_ image: ImageItem, tag: TagFolder, in tags: [TagFolder], context: ModelContext) async {
guard let hash = hashesByURL[image.url] else { return }
context.insert(PostedImage(
contentHash: hash,
fileName: image.name,
tag: tag.name,
service: ImageLibrary.serviceFolderName
))
try? context.save()
await scan(tags, context: context)
}
/// Removes an image's posted record so it will be treated as new again.
func forgetPostedRecord(for image: ImageItem, in tags: [TagFolder], context: ModelContext) async {
guard let hash = hashesByURL[image.url] else { return }
try? context.delete(model: PostedImage.self, where: #Predicate { $0.contentHash == hash })
try? context.save()
await scan(tags, context: context)
}
}
+18
View File
@@ -0,0 +1,18 @@
import Foundation
/// A single image file waiting in one of the tag folders.
struct ImageItem: Identifiable, Hashable {
let url: URL
var id: URL { url }
var name: String { url.lastPathComponent }
}
/// A folder inside the Tumblr directory. The folder's name becomes the post's tag.
struct TagFolder: Identifiable, Hashable {
let url: URL
var images: [ImageItem]
var id: URL { url }
var name: String { url.lastPathComponent }
}
@@ -0,0 +1,38 @@
import Foundation
/// Remembers which IJSS folder the user picked, across app launches.
///
/// The app runs in the macOS App Sandbox, so it may only touch folders the user
/// has explicitly chosen. A "security-scoped bookmark" is how macOS lets an app
/// keep that permission after it quits and relaunches.
enum LibraryLocationStore {
private static let bookmarkKey = "libraryFolderBookmark"
/// Saves a bookmark for the folder. Call while you have access to `url`.
static func save(_ url: URL) throws {
let data = try url.bookmarkData(
options: .withSecurityScope,
includingResourceValuesForKeys: nil,
relativeTo: nil
)
UserDefaults.standard.set(data, forKey: bookmarkKey)
}
/// Resolves the saved bookmark, if there is one.
/// `isStale` means the folder moved or was renamed and the bookmark should be saved again.
static func restore() -> (url: URL, isStale: Bool)? {
guard let data = UserDefaults.standard.data(forKey: bookmarkKey) else { return nil }
var isStale = false
guard let url = try? URL(
resolvingBookmarkData: data,
options: .withSecurityScope,
relativeTo: nil,
bookmarkDataIsStale: &isStale
) else { return nil }
return (url, isStale)
}
static func clear() {
UserDefaults.standard.removeObject(forKey: bookmarkKey)
}
}
+38
View File
@@ -0,0 +1,38 @@
import Foundation
import SwiftData
/// A record of one image that has been posted. Stored in the app's SQLite
/// database (via SwiftData) in Application Support.
///
/// Images are matched by `contentHash` (a fingerprint of the file's contents),
/// so a renamed or moved copy of an already-posted image is still recognized.
@Model
final class PostedImage {
@Attribute(.unique) var contentHash: String
var fileName: String
var tag: String
var service: String
var postedAt: Date
/// Where the posted file was archived (set once posting is built).
var archivedFilePath: String?
/// The post's ID on the service, if known.
var remotePostID: String?
init(
contentHash: String,
fileName: String,
tag: String,
service: String,
postedAt: Date = .now,
archivedFilePath: String? = nil,
remotePostID: String? = nil
) {
self.contentHash = contentHash
self.fileName = fileName
self.tag = tag
self.service = service
self.postedAt = postedAt
self.archivedFilePath = archivedFilePath
self.remotePostID = remotePostID
}
}
+36
View File
@@ -0,0 +1,36 @@
import Foundation
/// The order images are sent to the Tumblr queue.
enum PostingOrder: String, CaseIterable, Identifiable {
/// Alternate between folders (one from Cyberpunk, one from Neon, …) so tags are spread out.
case mixed
/// All of one folder, then all of the next, in alphabetical order.
case folderByFolder
var id: Self { self }
var title: String {
switch self {
case .mixed: "Mixed (alternate between folders)"
case .folderByFolder: "Folder by folder"
}
}
}
/// Keys and defaults for posting preferences, stored in UserDefaults via `@AppStorage`.
enum PostingSettings {
static let orderKey = "postingOrder"
static let delayKey = "postingDelaySeconds"
static let defaultOrder = PostingOrder.mixed
/// Courtesy pause between posts, in seconds.
static let defaultDelay = 10
static let delayRange = 5...120
/// Tumblr's per-user daily limit on new posts.
static let dailyPostLimit = 250
/// Tumblr's per-image upload limit.
static let maxFileSize = 10 * 1024 * 1024
/// Image types Tumblr accepts for upload.
static let supportedMIMETypes: Set<String> = ["image/jpeg", "image/png", "image/gif", "image/webp"]
}
+230
View File
@@ -0,0 +1,230 @@
import Foundation
import ImageIO
import Observation
import SwiftData
import UniformTypeIdentifiers
/// The results of one posting run, shown to the user when it finishes.
struct PostingReport {
var blogName: String
var posted: [String] = []
var failures: [(fileName: String, reason: String)] = []
/// Posted successfully, but the file couldn't be moved into `_Posted`.
var archiveFailures: [String] = []
/// Why posting ended early, if it did.
var stopReason: String?
}
/// Posts a batch of images to the Tumblr queue, one at a time, with a pause between each.
///
/// For each image:
/// 1. Upload it to the queue with its folder name as the tag.
/// 2. Record it in the database right away, so it can never be posted twice.
/// 3. Move the file into `Tumblr/_Posted/<Tag>/`.
@Observable
final class TumblrPoster {
private(set) var isPosting = false
private(set) var completedCount = 0
private(set) var totalCount = 0
private(set) var currentFileName: String?
/// True during the courtesy pause between posts.
private(set) var isPausing = false
/// Set when a run finishes; the UI shows it and then clears it.
var report: PostingReport?
/// Set by Stop. Checked between posts rather than cancelling mid-upload, because
/// an upload interrupted halfway might still have created the post on Tumblr.
private(set) var isStopRequested = false
/// Stop after this many failures in a row. If several fail back-to-back,
/// something bigger is wrong (e.g. the queue is full), and continuing won't help.
private let maxConsecutiveFailures = 3
func start(
_ items: [QueuedImage],
blogName: String,
delaySeconds: Int,
account: TumblrAccount,
library: ImageLibrary,
context: ModelContext
) {
guard !isPosting, !items.isEmpty else { return }
isPosting = true
isStopRequested = false
completedCount = 0
totalCount = items.count
Task {
let report = await run(items, blogName: blogName, delaySeconds: delaySeconds,
account: account, library: library, context: context)
isPosting = false
isPausing = false
currentFileName = nil
self.report = report
}
}
/// Stops after the current upload finishes. Images already posted stay posted.
func stop() {
isStopRequested = true
}
private func run(
_ items: [QueuedImage],
blogName: String,
delaySeconds: Int,
account: TumblrAccount,
library: ImageLibrary,
context: ModelContext
) async -> PostingReport {
var report = PostingReport(blogName: blogName)
var consecutiveFailures = 0
for (index, item) in items.enumerated() {
// Courtesy pause between posts (not before the first one).
// Sleep in short steps so Stop takes effect quickly.
if index > 0 {
isPausing = true
let resumeAt = ContinuousClock.now + .seconds(delaySeconds)
while !isStopRequested, ContinuousClock.now < resumeAt {
try? await Task.sleep(for: .milliseconds(250))
}
isPausing = false
}
if isStopRequested {
report.stopReason = "You stopped posting before it finished."
break
}
currentFileName = item.image.name
do {
let postID = try await post(item, blogName: blogName, account: account)
// Record first, then move. If the move fails, the image is still
// known to be posted and won't be sent again.
let record = PostedImage(
contentHash: item.contentHash,
fileName: item.image.name,
tag: item.tagName,
service: ImageLibrary.serviceFolderName,
remotePostID: postID
)
context.insert(record)
try? context.save()
do {
let archivedURL = try library.archive(item.image, tagName: item.tagName)
record.archivedFilePath = archivedURL.path(percentEncoded: false)
try? context.save()
} catch {
report.archiveFailures.append(item.image.name)
}
library.reload()
report.posted.append(item.image.name)
consecutiveFailures = 0
} catch {
report.failures.append((item.image.name, error.localizedDescription))
consecutiveFailures += 1
if let reason = Self.reasonToStop(after: error) {
report.stopReason = reason
break
}
if consecutiveFailures >= maxConsecutiveFailures {
report.stopReason = "Stopped after \(maxConsecutiveFailures) failures in a row. Your queue may be full, or Tumblr may be having problems."
break
}
}
completedCount = index + 1
}
return report
}
/// Checks the file, then uploads it. Returns Tumblr's post ID.
private func post(_ item: QueuedImage, blogName: String, account: TumblrAccount) async throws -> String {
let url = item.image.url
let values = try url.resourceValues(forKeys: [.fileSizeKey, .contentTypeKey])
guard let mimeType = values.contentType?.preferredMIMEType,
PostingSettings.supportedMIMETypes.contains(mimeType) else {
throw PostingError.unsupportedFormat(url.pathExtension.uppercased())
}
if let size = values.fileSize, size > PostingSettings.maxFileSize {
throw PostingError.fileTooLarge
}
let (width, height) = Self.pixelSize(of: url)
return try await TumblrAPI.createQueuedImagePost(
imageURL: url,
mimeType: mimeType,
width: width,
height: height,
tags: [item.tagName],
blogName: blogName,
accessToken: try await account.validAccessToken()
)
}
/// Errors that mean every remaining post would fail too.
private static func reasonToStop(after error: Error) -> String? {
switch error {
case TumblrError.notConnected, TumblrError.missingCredentials:
return error.localizedDescription
case TumblrError.server(let status, _) where status == 401 || status == 403:
return "Tumblr didn't accept IJSS's login. Try disconnecting and connecting again in Settings."
case TumblrError.server(let status, _) where status == 429:
return "Tumblr's posting limit was reached. Try again tomorrow."
default:
return nil
}
}
/// Reads the image's width and height without loading the whole image.
private static func pixelSize(of url: URL) -> (Int?, Int?) {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] else {
return (nil, nil)
}
return (properties[kCGImagePropertyPixelWidth] as? Int, properties[kCGImagePropertyPixelHeight] as? Int)
}
}
enum PostingError: LocalizedError {
case unsupportedFormat(String)
case fileTooLarge
var errorDescription: String? {
switch self {
case .unsupportedFormat(let format):
"Tumblr doesn't accept \(format.isEmpty ? "this kind of" : format) files. Use JPEG, PNG, GIF, or WebP."
case .fileTooLarge:
"The file is larger than Tumblr's 10 MB limit."
}
}
}
extension PostingReport: CustomStringConvertible {
var description: String {
var parts: [String] = []
if posted.isEmpty {
parts.append("No images were added to \(blogName)'s queue.")
} else {
let noun = posted.count == 1 ? "image" : "images"
parts.append("Added \(posted.count) \(noun) to \(blogName)'s queue.")
}
if !failures.isEmpty {
// Keep the alert a readable size if many failed.
let shown = failures.prefix(8).map { "• \($0.fileName): \($0.reason)" }
let more = failures.count > 8 ? ["…and \(failures.count - 8) more."] : []
parts.append("\(failures.count) couldn't be posted:\n" + (shown + more).joined(separator: "\n"))
}
if !archiveFailures.isEmpty {
parts.append("These were posted but couldn't be moved to the _Posted folder (they won't be posted again):\n"
+ archiveFailures.map { "• \($0)" }.joined(separator: "\n"))
}
if let stopReason {
parts.append(stopReason)
}
return parts.joined(separator: "\n\n")
}
}
+43
View File
@@ -0,0 +1,43 @@
import SwiftData
import SwiftUI
@main struct MyApp: App {
/// Shared objects, handed to every view through the environment.
@State private var library = ImageLibrary()
@State private var scanner = ImageScanner()
@State private var tumblr = TumblrAccount()
@State private var poster = TumblrPoster()
/// The posted-images database. SwiftData keeps it as a SQLite file in the app's
/// Application Support folder. One container is shared by both windows so they
/// always see the same data.
private let database: ModelContainer
init() {
do {
database = try ModelContainer(for: PostedImage.self)
} catch {
fatalError("IJSS couldn't open its posted-images database: \(error)")
}
}
var body: some Scene {
Window("Image Junky", id: "main") {
ContentView()
.environment(library)
.environment(scanner)
.environment(tumblr)
.environment(poster)
}
.modelContainer(database)
Settings {
SettingsView()
.environment(library)
.environment(scanner)
.environment(tumblr)
.environment(poster)
}
.modelContainer(database)
}
}
+44
View File
@@ -0,0 +1,44 @@
import Foundation
import Security
/// Saves small secrets (like login tokens) in the macOS Keychain,
/// the system's encrypted password store.
enum KeychainStore {
private static let service = Bundle.main.bundleIdentifier ?? "IJSS"
static func save(_ data: Data, account: String) throws {
delete(account: account)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: data,
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
}
}
static func load(account: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { return nil }
return result as? Data
}
static func delete(account: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
SecItemDelete(query as CFDictionary)
}
}
@@ -0,0 +1,134 @@
import Foundation
import Network
/// A tiny, temporary web server on `http://localhost:8765` that waits for the single
/// redirect Tumblr sends after the user clicks Allow in their browser.
///
/// It only listens on the loopback interface, so nothing outside this Mac can reach it,
/// and it shuts down as soon as the redirect arrives (or the login is cancelled).
final class LoopbackRedirectServer {
enum ServerError: LocalizedError {
case portUnavailable(UInt16)
var errorDescription: String? {
switch self {
case .portUnavailable(let port):
"IJSS couldn't listen for the Tumblr login on port \(port). Another app may be using it. Quit other apps that might be using that port and try again."
}
}
}
private let port: UInt16
private let path: String
private var listener: NWListener?
private var continuation: CheckedContinuation<URL, Error>?
init(port: UInt16, path: String) {
self.port = port
self.path = path
}
/// Starts listening, calls `whenReady` (to open the browser), then waits for the
/// redirect and returns its full URL, including the `code` and `state` query items.
func receiveRedirect(whenReady: @escaping () -> Void) async throws -> URL {
guard let nwPort = NWEndpoint.Port(rawValue: port) else {
throw ServerError.portUnavailable(port)
}
let parameters = NWParameters.tcp
parameters.requiredInterfaceType = .loopback
parameters.allowLocalEndpointReuse = true
let listener: NWListener
do {
listener = try NWListener(using: parameters, on: nwPort)
} catch {
throw ServerError.portUnavailable(port)
}
self.listener = listener
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
// All callbacks are delivered on the main queue (see `start(queue:)`).
listener.stateUpdateHandler = { [weak self] state in
MainActor.assumeIsolated {
switch state {
case .ready:
whenReady()
case .failed:
self?.finish(.failure(ServerError.portUnavailable(self?.port ?? 0)))
default:
break
}
}
}
listener.newConnectionHandler = { [weak self] connection in
MainActor.assumeIsolated {
self?.handle(connection)
}
}
listener.start(queue: .main)
}
} onCancel: {
Task { @MainActor in
self.finish(.failure(CancellationError()))
}
}
}
private func handle(_ connection: NWConnection) {
connection.start(queue: .main)
connection.receive(minimumIncompleteLength: 1, maximumLength: 16_384) { [weak self] data, _, _, _ in
MainActor.assumeIsolated {
guard let self else {
connection.cancel()
return
}
// The request's first line looks like:
// GET /oauth-callback?code=abc&state=xyz HTTP/1.1
let request = data.flatMap { String(data: $0, encoding: .utf8) } ?? ""
let parts = request.split(separator: " ", maxSplits: 2)
guard parts.count >= 2, parts[0] == "GET",
let url = URL(string: "http://localhost:\(self.port)\(parts[1])"),
url.path() == self.path else {
// Browsers also ask for things like /favicon.ico. Ignore those and keep waiting.
Self.respond(to: connection, status: "404 Not Found", html: "Not found")
return
}
Self.respond(to: connection, status: "200 OK", html: Self.finishedPage)
self.finish(.success(url))
}
}
}
private func finish(_ result: Result<URL, Error>) {
listener?.cancel()
listener = nil
continuation?.resume(with: result)
continuation = nil
}
private static func respond(to connection: NWConnection, status: String, html: String) {
let body = Data(html.utf8)
let header = """
HTTP/1.1 \(status)\r
Content-Type: text/html; charset=utf-8\r
Content-Length: \(body.count)\r
Connection: close\r
\r
"""
connection.send(content: Data(header.utf8) + body, completion: .contentProcessed { _ in
connection.cancel()
})
}
private static let finishedPage = """
<!doctype html>
<html><head><meta charset="utf-8"><title>Image Junky</title>
<style>body{font:16px -apple-system,sans-serif;text-align:center;padding-top:80px;color:#333}
@media(prefers-color-scheme:dark){body{background:#1e1e1e;color:#ddd}}</style></head>
<body><h1>All done!</h1><p>You can close this tab and go back to Image Junky.</p></body></html>
"""
}
+85
View File
@@ -0,0 +1,85 @@
import Foundation
extension TumblrAPI {
/// Uploads one image as a new post at the end of the blog's queue.
/// Returns the new post's ID.
///
/// Uses Tumblr's "Neue Post Format": a multipart form whose `json` part describes
/// the post, and whose second part is the image file. The image block's
/// `identifier` must match the file part's name.
static func createQueuedImagePost(
imageURL: URL,
mimeType: String,
width: Int?,
height: Int?,
tags: [String],
blogName: String,
accessToken: String
) async throws -> String {
let identifier = "image0"
var media: [String: Any] = ["type": mimeType, "identifier": identifier]
if let width, let height {
media["width"] = width
media["height"] = height
}
let post: [String: Any] = [
"content": [["type": "image", "media": [media]]],
"state": "queue",
"tags": tags.joined(separator: ","),
]
let json = try JSONSerialization.data(withJSONObject: post)
let imageData = try Data(contentsOf: imageURL)
// Build the multipart body by hand. Each part starts with "--boundary".
let boundary = "IJSS-\(UUID().uuidString)"
var body = Data()
func append(_ string: String) { body.append(Data(string.utf8)) }
append("--\(boundary)\r\n")
append("Content-Disposition: form-data; name=\"json\"\r\n")
append("Content-Type: application/json\r\n\r\n")
body.append(json)
append("\r\n")
// A plain ASCII file name avoids problems with unusual characters in the original name.
let fileExtension = imageURL.pathExtension.isEmpty ? "jpg" : imageURL.pathExtension.lowercased()
append("--\(boundary)\r\n")
append("Content-Disposition: form-data; name=\"\(identifier)\"; filename=\"image.\(fileExtension)\"\r\n")
append("Content-Type: \(mimeType)\r\n\r\n")
body.append(imageData)
append("\r\n")
append("--\(boundary)--\r\n")
let blog = blogName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? blogName
var request = URLRequest(url: try url("\(apiBaseURL)/blog/\(blog)/posts"))
request.httpMethod = "POST"
request.timeoutInterval = 120 // Uploads of large images can take a while.
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.httpBody = body
struct Envelope: Decodable {
struct Response: Decodable {
let id: PostID
}
let response: Response
}
let envelope: Envelope = try await send(request)
return envelope.response.id.value
}
}
/// Tumblr documents post IDs as strings, but accept a number too, just in case.
private struct PostID: Decodable {
let value: String
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let string = try? container.decode(String.self) {
value = string
} else {
value = String(try container.decode(Int64.self))
}
}
}
+164
View File
@@ -0,0 +1,164 @@
import Foundation
/// Login tokens for one Tumblr user. Saved in the Keychain.
struct TumblrTokens: Codable {
var accessToken: String
var refreshToken: String?
var expiresAt: Date
/// Treat the token as expired a minute early, so it can't run out mid-request.
var isExpired: Bool { expiresAt.addingTimeInterval(-60) < .now }
}
/// One of the user's blogs. A Tumblr account can have several.
struct TumblrBlog: Codable, Identifiable, Hashable {
let name: String
let title: String?
let url: String?
let primary: Bool?
var id: String { name }
var displayName: String {
guard let title, !title.isEmpty, title != name else { return name }
return "\(title) (\(name))"
}
}
struct TumblrUser {
let name: String
let blogs: [TumblrBlog]
}
enum TumblrError: LocalizedError {
case missingCredentials
case notConnected
case authorizationDenied
case invalidRedirect
case server(status: Int, message: String)
var errorDescription: String? {
switch self {
case .missingCredentials:
"IJSS doesn't have its Tumblr app credentials yet. Paste the OAuth Consumer Key and Secret Key into TumblrCredentials.plist in Xcode, then rebuild."
case .notConnected:
"IJSS isn't connected to Tumblr. Connect your account and try again."
case .authorizationDenied:
"Tumblr access wasn't allowed, so IJSS isn't connected."
case .invalidRedirect:
"Tumblr's response didn't look right, so IJSS didn't connect. Please try again."
case .server(let status, let message):
"Tumblr returned an error (\(status)): \(message)"
}
}
}
/// The raw web requests to Tumblr's API. Kept separate from `TumblrAccount`
/// so the posting code in the next step can reuse them.
enum TumblrAPI {
static let authorizeURL = "https://www.tumblr.com/oauth2/authorize"
static let tokenURL = "https://api.tumblr.com/v2/oauth2/token"
static let apiBaseURL = "https://api.tumblr.com/v2"
// MARK: - Login
/// Trades the one-time code from the login redirect for access and refresh tokens.
static func exchangeCode(_ code: String, credentials: TumblrCredentials) async throws -> TumblrTokens {
try await requestTokens([
"grant_type": "authorization_code",
"code": code,
"client_id": credentials.consumerKey,
"client_secret": credentials.secretKey,
"redirect_uri": TumblrCredentials.redirectURI,
])
}
/// Gets a fresh access token using the long-lived refresh token, without asking the user to log in again.
static func refresh(_ refreshToken: String, credentials: TumblrCredentials) async throws -> TumblrTokens {
try await requestTokens([
"grant_type": "refresh_token",
"refresh_token": refreshToken,
"client_id": credentials.consumerKey,
"client_secret": credentials.secretKey,
])
}
private static func requestTokens(_ form: [String: String]) async throws -> TumblrTokens {
var request = URLRequest(url: try url(tokenURL))
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.httpBody = Data(formEncode(form).utf8)
struct TokenResponse: Decodable {
let accessToken: String
let expiresIn: Int
let refreshToken: String?
}
let response: TokenResponse = try await send(request)
return TumblrTokens(
accessToken: response.accessToken,
refreshToken: response.refreshToken,
expiresAt: .now.addingTimeInterval(TimeInterval(response.expiresIn))
)
}
// MARK: - Account info
/// Fetches the logged-in user's name and blogs.
static func userInfo(accessToken: String) async throws -> TumblrUser {
var request = URLRequest(url: try url("\(apiBaseURL)/user/info"))
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
struct Envelope: Decodable {
struct Response: Decodable {
struct User: Decodable {
let name: String
let blogs: [TumblrBlog]
}
let user: User
}
let response: Response
}
let envelope: Envelope = try await send(request)
return TumblrUser(name: envelope.response.user.name, blogs: envelope.response.user.blogs)
}
// MARK: - Helpers
static func send<Response: Decodable>(_ request: URLRequest) async throws -> Response {
let (data, urlResponse) = try await URLSession.shared.data(for: request)
let status = (urlResponse as? HTTPURLResponse)?.statusCode ?? 0
guard (200..<300).contains(status) else {
throw TumblrError.server(status: status, message: errorMessage(from: data))
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode(Response.self, from: data)
}
/// Pulls a readable message out of Tumblr's error responses, which come in a few shapes.
private static func errorMessage(from data: Data) -> String {
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
if let description = json["error_description"] as? String { return description }
if let errors = json["errors"] as? [[String: Any]],
let detail = errors.first?["detail"] as? String { return detail }
if let meta = json["meta"] as? [String: Any], let message = meta["msg"] as? String { return message }
if let error = json["error"] as? String { return error }
}
return String(data: data.prefix(300), encoding: .utf8) ?? "Unknown error"
}
static func url(_ string: String) throws -> URL {
guard let url = URL(string: string) else { throw URLError(.badURL) }
return url
}
private static func formEncode(_ form: [String: String]) -> String {
var allowed = CharacterSet.alphanumerics
allowed.insert(charactersIn: "-._~")
return form
.map { key, value in
"\(key)=\(value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value)"
}
.joined(separator: "&")
}
}
+162
View File
@@ -0,0 +1,162 @@
import AppKit
import Foundation
import Observation
/// The user's Tumblr connection: logging in and out, keeping the login fresh,
/// and choosing which blog to post to.
///
/// How logging in works:
/// 1. IJSS starts a tiny local web server on `localhost:8765`.
/// 2. It opens Tumblr's "Allow access?" page in the user's browser.
/// 3. After the user clicks Allow, Tumblr redirects the browser to
/// `http://localhost:8765/oauth-callback?code=…`, which IJSS receives.
/// 4. IJSS trades that one-time code for login tokens and stores them in the Keychain.
@Observable
final class TumblrAccount {
private(set) var username: String?
private(set) var blogs: [TumblrBlog] = []
private(set) var isConnecting = false
var errorMessage: String?
/// The blog that posts go to. Remembered between launches.
var selectedBlogName: String? {
didSet { UserDefaults.standard.set(selectedBlogName, forKey: Self.selectedBlogKey) }
}
var selectedBlog: TumblrBlog? {
blogs.first { $0.name == selectedBlogName }
}
var isConnected: Bool { tokens != nil }
var hasCredentials: Bool { credentials != nil }
private let credentials = TumblrCredentials.load()
private var tokens: TumblrTokens?
@ObservationIgnored private var connectTask: Task<Void, Never>?
private static let tokensKeychainAccount = "tumblr-tokens"
private static let selectedBlogKey = "tumblrSelectedBlog"
init() {
selectedBlogName = UserDefaults.standard.string(forKey: Self.selectedBlogKey)
if let data = KeychainStore.load(account: Self.tokensKeychainAccount) {
tokens = try? JSONDecoder().decode(TumblrTokens.self, from: data)
}
if isConnected {
Task { await loadUserInfo() }
}
}
// MARK: - Connecting
func connect() {
connectTask?.cancel()
connectTask = Task { await performConnect() }
}
func cancelConnect() {
connectTask?.cancel()
}
/// Logs out of Tumblr in IJSS. (The user can also revoke access on tumblr.com.)
func disconnect() {
KeychainStore.delete(account: Self.tokensKeychainAccount)
tokens = nil
username = nil
blogs = []
}
private func performConnect() async {
guard let credentials else {
errorMessage = TumblrError.missingCredentials.errorDescription
return
}
isConnecting = true
defer { isConnecting = false }
// A random value Tumblr sends back unchanged, so we can tell the redirect
// really belongs to the login we started.
let state = UUID().uuidString
var components = URLComponents(string: TumblrAPI.authorizeURL)
components?.queryItems = [
URLQueryItem(name: "client_id", value: credentials.consumerKey),
URLQueryItem(name: "response_type", value: "code"),
// basic: read account info. write: create posts. offline_access: stay logged in.
URLQueryItem(name: "scope", value: "basic write offline_access"),
URLQueryItem(name: "state", value: state),
URLQueryItem(name: "redirect_uri", value: TumblrCredentials.redirectURI),
]
guard let authorizeURL = components?.url else { return }
do {
let server = LoopbackRedirectServer(
port: TumblrCredentials.redirectPort,
path: TumblrCredentials.redirectPath
)
let redirect = try await server.receiveRedirect {
NSWorkspace.shared.open(authorizeURL)
}
NSApp.activate() // Bring IJSS back in front of the browser.
let query = URLComponents(url: redirect, resolvingAgainstBaseURL: false)?.queryItems ?? []
func value(_ name: String) -> String? { query.first { $0.name == name }?.value }
if value("error") != nil { throw TumblrError.authorizationDenied }
guard value("state") == state, let code = value("code") else { throw TumblrError.invalidRedirect }
try save(try await TumblrAPI.exchangeCode(code, credentials: credentials))
await loadUserInfo()
} catch is CancellationError {
// The user clicked Cancel. Nothing to report.
} catch {
errorMessage = error.localizedDescription
}
}
// MARK: - Using the connection
/// Returns a working access token, refreshing it first if it has expired.
/// Posting code calls this before every request.
func validAccessToken() async throws -> String {
guard let credentials else { throw TumblrError.missingCredentials }
guard let tokens else { throw TumblrError.notConnected }
guard tokens.isExpired else { return tokens.accessToken }
guard let refreshToken = tokens.refreshToken else {
disconnect()
throw TumblrError.notConnected
}
do {
var refreshed = try await TumblrAPI.refresh(refreshToken, credentials: credentials)
// Tumblr normally sends a new refresh token; keep the old one if it doesn't.
refreshed.refreshToken = refreshed.refreshToken ?? refreshToken
try save(refreshed)
return refreshed.accessToken
} catch TumblrError.server(let status, _) where status == 400 || status == 401 {
// The refresh token was rejected (e.g. access was revoked on tumblr.com).
disconnect()
throw TumblrError.notConnected
}
}
/// Loads the user's name and list of blogs.
func loadUserInfo() async {
do {
let user = try await TumblrAPI.userInfo(accessToken: try await validAccessToken())
username = user.name
blogs = user.blogs
// Default to the primary blog if nothing (or a blog that no longer exists) is selected.
if selectedBlog == nil {
selectedBlogName = (blogs.first { $0.primary == true } ?? blogs.first)?.name
}
} catch {
errorMessage = error.localizedDescription
}
}
private func save(_ newTokens: TumblrTokens) throws {
try KeychainStore.save(try JSONEncoder().encode(newTokens), account: Self.tokensKeychainAccount)
tokens = newTokens
}
}
+28
View File
@@ -0,0 +1,28 @@
import Foundation
/// The IJSS app's own Tumblr registration (from tumblr.com/oauth/apps).
/// These identify the *app*, not a user. Each user still logs in with their own account.
///
/// Values are read from `TumblrCredentials.plist`, which is kept out of Git so the
/// secret doesn't end up in the repository.
struct TumblrCredentials {
let consumerKey: String
let secretKey: String
/// Must exactly match the OAuth2 redirect URL registered with Tumblr.
static let redirectURI = "http://localhost:8765/oauth-callback"
static let redirectPort: UInt16 = 8765
static let redirectPath = "/oauth-callback"
/// Loads the credentials, or returns `nil` if the file is missing or still has placeholders.
static func load() -> TumblrCredentials? {
guard let url = Bundle.main.url(forResource: "TumblrCredentials", withExtension: "plist"),
let values = NSDictionary(contentsOf: url) as? [String: String],
let key = values["ConsumerKey"], let secret = values["SecretKey"],
!key.isEmpty, !secret.isEmpty,
!key.hasPrefix("PASTE_"), !secret.hasPrefix("PASTE_") else {
return nil
}
return TumblrCredentials(consumerKey: key, secretKey: secret)
}
}
+199
View File
@@ -0,0 +1,199 @@
import AppKit
import QuickLookThumbnailing
import SwiftData
import SwiftUI
/// Shows the images in one tag folder as a grid of thumbnails.
/// Images can be added with the toolbar button or by dragging files in from Finder.
struct ImageGridView: View {
@Environment(ImageLibrary.self) private var library
@Environment(ImageScanner.self) private var scanner
let tag: TagFolder
@State private var isImporting = false
@State private var isDropTargeted = false
private let columns = [GridItem(.adaptive(minimum: 140, maximum: 220), spacing: 16)]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(tag.images) { image in
ThumbnailView(image: image, tag: tag, status: scanner.status(for: image))
}
}
.padding()
}
.overlay {
if tag.images.isEmpty {
ContentUnavailableView {
Label("No Images", systemImage: "photo.on.rectangle.angled")
} description: {
Text("Drag images here, or click Add Images.")
}
}
}
.overlay {
// Highlight the window while something is being dragged over it.
if isDropTargeted {
RoundedRectangle(cornerRadius: 12)
.strokeBorder(.tint, lineWidth: 3)
.padding(4)
}
}
.dropDestination(for: URL.self) { urls, _ in
library.addImages(urls, to: tag)
}
.onDropSessionUpdated { session in
isDropTargeted = session.phase == .active
}
.navigationTitle(tag.name)
.navigationSubtitle(subtitle)
.toolbar {
Button("Add Images", systemImage: "plus") {
isImporting = true
}
}
.fileImporter(
isPresented: $isImporting,
allowedContentTypes: [.image],
allowsMultipleSelection: true
) { result in
switch result {
case .success(let urls):
library.addImages(urls, to: tag)
case .failure(let error):
library.errorMessage = error.localizedDescription
}
}
}
private var subtitle: String {
let readyCount = tag.images.count { scanner.status(for: $0) == .ready }
return "\(tag.images.count) images · \(readyCount) ready to post"
}
}
/// One thumbnail in the grid. Uses Quick Look to make small previews quickly,
/// instead of loading full-size images into memory.
struct ThumbnailView: View {
let image: ImageItem
let tag: TagFolder
let status: ImageStatus
@Environment(ImageLibrary.self) private var library
@Environment(ImageScanner.self) private var scanner
@Environment(\.modelContext) private var modelContext
@Environment(\.displayScale) private var displayScale
@State private var thumbnail: NSImage?
private let size = CGSize(width: 180, height: 180)
var body: some View {
VStack(spacing: 6) {
Group {
if let thumbnail {
Image(nsImage: thumbnail)
.resizable()
.scaledToFit()
} else {
ProgressView()
}
}
.frame(width: 140, height: 140)
// Fade out images that won't be posted.
.opacity(isSkipped ? 0.45 : 1)
.overlay(alignment: .topTrailing) {
StatusBadge(status: status)
}
Text(image.name)
.font(.caption)
.lineLimit(1)
.truncationMode(.middle)
}
.help(status.explanation)
.contextMenu {
Button("Show in Finder") {
NSWorkspace.shared.activateFileViewerSelecting([image.url])
}
Divider()
switch status {
case .ready, .duplicate:
Button("Mark as Already Posted") {
Task { await scanner.markAsPosted(image, tag: tag, in: library.tags, context: modelContext) }
}
case .alreadyPosted:
Button("Forget Posted Record") {
Task { await scanner.forgetPostedRecord(for: image, in: library.tags, context: modelContext) }
}
case .checking, .unreadable:
EmptyView()
}
}
.task(id: image.url) {
let request = QLThumbnailGenerator.Request(
fileAt: image.url,
size: size,
scale: displayScale,
representationTypes: .thumbnail
)
thumbnail = try? await QLThumbnailGenerator.shared
.generateBestRepresentation(for: request)
.nsImage
}
}
private var isSkipped: Bool {
switch status {
case .alreadyPosted, .duplicate, .unreadable: true
case .checking, .ready: false
}
}
}
/// A small icon in the corner of a thumbnail showing why it won't be posted.
/// Ready images get no badge, to keep the grid uncluttered.
private struct StatusBadge: View {
let status: ImageStatus
var body: some View {
if let (symbol, color) = appearance {
Image(systemName: symbol)
.font(.title3)
.symbolRenderingMode(.palette)
.foregroundStyle(.white, color)
.padding(4)
.accessibilityLabel(status.explanation)
}
}
private var appearance: (String, Color)? {
switch status {
case .alreadyPosted: ("checkmark.circle.fill", .green)
case .duplicate: ("square.on.square.fill", .orange)
case .unreadable: ("exclamationmark.triangle.fill", .red)
case .checking, .ready: nil
}
}
}
extension ImageStatus {
/// A plain-language description, used for tooltips and VoiceOver.
var explanation: String {
switch self {
case .checking:
"Checking…"
case .ready:
"Ready to post"
case .alreadyPosted(let date, let tag):
"Already posted on \(date.formatted(date: .abbreviated, time: .omitted)) with the tag “\(tag)”. It won't be posted again."
case .duplicate(let original):
"Same image as “\(original.lastPathComponent)” in “\(original.deletingLastPathComponent().lastPathComponent)”. Only that copy will be posted."
case .unreadable:
"This file couldn't be read."
}
}
private var status: ImageStatus { self }
}
+40
View File
@@ -0,0 +1,40 @@
import SwiftUI
/// A bar along the bottom of the window while images are being posted.
struct PostingProgressView: View {
@Environment(TumblrPoster.self) private var poster
var body: some View {
HStack(spacing: 12) {
ProgressView(value: Double(poster.completedCount), total: Double(max(poster.totalCount, 1)))
.frame(width: 160)
Text(statusText)
.lineLimit(1)
.truncationMode(.middle)
.foregroundStyle(.secondary)
Spacer()
Button(poster.isStopRequested ? "Stopping…" : "Stop") {
poster.stop()
}
.disabled(poster.isStopRequested)
.help("Stop after the current image. Images already posted stay in your queue.")
}
.padding(.horizontal)
.padding(.vertical, 10)
.background(.bar)
}
private var statusText: String {
if poster.isStopRequested {
return "Stopping after the current image…"
}
if poster.isPausing {
return "Posted \(poster.completedCount) of \(poster.totalCount). Pausing before the next one…"
}
let position = min(poster.completedCount + 1, poster.totalCount)
return "Posting \(position) of \(poster.totalCount): \(poster.currentFileName ?? "")"
}
}
+245
View File
@@ -0,0 +1,245 @@
import AppKit
import SwiftData
import SwiftUI
/// The app's Settings window (IJSS ▸ Settings…, or ⌘,).
struct SettingsView: View {
@Environment(TumblrAccount.self) private var account
@Environment(\.appearsActive) private var appearsActive
var body: some View {
TabView {
Tab("General", systemImage: "gearshape") {
GeneralSettingsView()
}
Tab("Tumblr", systemImage: "person.crop.circle") {
TumblrSettingsView()
}
Tab("Posting", systemImage: "paperplane") {
PostingSettingsView()
}
}
.frame(width: 520)
.alert(
"Tumblr",
isPresented: Binding(
get: { appearsActive && account.errorMessage != nil },
set: { if !$0 { account.errorMessage = nil } }
)
) {
Button("OK") {}
} message: {
Text(account.errorMessage ?? "")
}
}
}
private struct GeneralSettingsView: View {
@Environment(ImageLibrary.self) private var library
var body: some View {
Form {
LabeledContent("IJSS Folder") {
VStack(alignment: .trailing) {
Text(library.rootURL?.path(percentEncoded: false) ?? "Not chosen")
.foregroundStyle(.secondary)
.textSelection(.enabled)
Button("Change…") {
if let url = FolderPicker.chooseLibraryFolder() {
library.useFolder(url)
}
}
}
}
PostedHistorySection()
}
.formStyle(.grouped)
}
}
/// Shows how much has been posted, and lets the user clear the history and archive.
private struct PostedHistorySection: View {
@Environment(ImageLibrary.self) private var library
@Environment(ImageScanner.self) private var scanner
@Environment(TumblrPoster.self) private var poster
@Environment(\.modelContext) private var modelContext
/// Every record in the posted-images database. Updates automatically.
@Query private var postedImages: [PostedImage]
@State private var archiveBytes: Int64?
@State private var isConfirmingClear = false
@State private var clearError: String?
var body: some View {
Section {
LabeledContent("History", value: summary)
HStack {
Button("Show Archive in Finder") {
if let archiveURL = library.archiveURL {
NSWorkspace.shared.open(archiveURL)
}
}
.disabled(!archiveExists)
Spacer()
Button("Clear History…", role: .destructive) {
isConfirmingClear = true
}
.disabled((postedImages.isEmpty && !archiveExists) || poster.isPosting)
.help(poster.isPosting ? "Wait for posting to finish first" : "")
}
} header: {
Text("Posted History")
} footer: {
Text("Posted images are moved to the _Posted folder. Clearing history moves that folder to the Trash and forgets which images have been posted. Posts already on Tumblr aren't affected.")
.font(.footnote)
.foregroundStyle(.secondary)
}
// Recalculate the archive size whenever the number of posted images changes.
.task(id: postedImages.count) {
await refreshArchiveSize()
}
.confirmationDialog(
"Clear Posted History?",
isPresented: $isConfirmingClear
) {
Button("Clear History and Move Archive to Trash", role: .destructive) {
Task { await clearHistory() }
}
Button("Cancel", role: .cancel) {}
} message: {
Text("IJSS will forget all \(postedImages.count) posted images and move the _Posted folder to the Trash. If you put any of those images back in a tag folder later, they'll be posted again.")
}
.alert(
"Couldn't Clear History",
isPresented: Binding(
get: { clearError != nil },
set: { if !$0 { clearError = nil } }
)
) {
Button("OK") {}
} message: {
Text(clearError ?? "")
}
}
private var archiveExists: Bool {
guard let archiveURL = library.archiveURL else { return false }
return FileManager.default.fileExists(atPath: archiveURL.path(percentEncoded: false))
}
private var summary: String {
let count = postedImages.count
let images = count == 1 ? "1 image posted" : "\(count) images posted"
guard let archiveBytes, archiveBytes > 0 else { return images }
let size = archiveBytes.formatted(.byteCount(style: .file))
return "\(images) · archive uses \(size)"
}
private func refreshArchiveSize() async {
guard let archiveURL = library.archiveURL else {
archiveBytes = nil
return
}
archiveBytes = await ImageLibrary.diskUsage(of: archiveURL)
}
/// Trashes the archive first. Only if that works is the database cleared, so the
/// two never get out of step (e.g. records gone but archived files left behind).
private func clearHistory() async {
do {
try library.trashArchive()
try modelContext.delete(model: PostedImage.self)
try modelContext.save()
} catch {
clearError = error.localizedDescription
}
library.reload()
await scanner.scan(library.tags, context: modelContext)
await refreshArchiveSize()
}
}
private struct PostingSettingsView: View {
@AppStorage(PostingSettings.orderKey) private var order = PostingSettings.defaultOrder
@AppStorage(PostingSettings.delayKey) private var delay = PostingSettings.defaultDelay
var body: some View {
Form {
Section {
Picker("Posting order", selection: $order) {
ForEach(PostingOrder.allCases) { order in
Text(order.title).tag(order)
}
}
.pickerStyle(.radioGroup)
Stepper(value: $delay, in: PostingSettings.delayRange, step: 5) {
LabeledContent("Pause between posts", value: "\(delay) seconds")
}
} footer: {
Text("Images are added to the end of your Tumblr queue. The pause keeps IJSS from sending posts too quickly.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
}
private struct TumblrSettingsView: View {
@Environment(TumblrAccount.self) private var account
var body: some View {
@Bindable var account = account
Form {
Section {
if !account.hasCredentials {
Label {
Text("The app's Tumblr credentials are missing. Paste the OAuth Consumer Key and Secret Key into **TumblrCredentials.plist** in Xcode, then rebuild.")
} icon: {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.yellow)
}
} else if account.isConnected {
LabeledContent("Signed in as", value: account.username ?? "Loading…")
Picker("Post to blog", selection: $account.selectedBlogName) {
ForEach(account.blogs) { blog in
Text(blog.displayName).tag(Optional(blog.name))
}
}
.disabled(account.blogs.isEmpty)
Button("Disconnect", role: .destructive) {
account.disconnect()
}
} else if account.isConnecting {
HStack {
ProgressView()
.controlSize(.small)
Text("Waiting for you to click **Allow** in your browser…")
Spacer()
Button("Cancel") {
account.cancelConnect()
}
}
} else {
Text("Connect your Tumblr account so IJSS can add images to your blog's queue. Your browser will open so you can log in to Tumblr and allow access.")
.foregroundStyle(.secondary)
Button("Connect to Tumblr…") {
account.connect()
}
}
} header: {
Text("Tumblr Account")
}
}
.formStyle(.grouped)
}
}
+62
View File
@@ -0,0 +1,62 @@
import AppKit
import SwiftUI
/// The left-hand list of tag folders inside the Tumblr folder.
struct SidebarView: View {
@Environment(ImageLibrary.self) private var library
@Binding var selection: TagFolder.ID?
@State private var isNamingNewFolder = false
@State private var newFolderName = ""
var body: some View {
List(selection: $selection) {
Section("Tumblr Tags") {
ForEach(library.tags) { tag in
Label(tag.name, systemImage: "tag")
.badge(tag.images.count)
.contextMenu {
Button("Show in Finder") {
NSWorkspace.shared.activateFileViewerSelecting([tag.url])
}
}
}
}
}
.overlay {
if library.tags.isEmpty {
ContentUnavailableView {
Label("No Tag Folders", systemImage: "folder.badge.plus")
} description: {
Text("Make a folder for each tag you want to post with.")
}
}
}
.safeAreaInset(edge: .bottom) {
VStack(alignment: .leading, spacing: 10) {
Button("New Folder", systemImage: "folder.badge.plus") {
newFolderName = ""
isNamingNewFolder = true
}
.buttonStyle(.borderless)
Divider()
TumblrStatusView()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
}
.alert("New Tag Folder", isPresented: $isNamingNewFolder) {
TextField("Tag name", text: $newFolderName)
Button("Create") {
if let id = library.createTag(named: newFolderName) {
selection = id
}
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Images in this folder will be posted with this name as their tag.")
}
}
}
+49
View File
@@ -0,0 +1,49 @@
import SwiftUI
/// A compact connection status line for the bottom of the sidebar.
struct TumblrStatusView: View {
@Environment(TumblrAccount.self) private var account
var body: some View {
HStack(spacing: 6) {
if account.isConnected {
Image(systemName: "circle.fill")
.font(.system(size: 8))
.foregroundStyle(.green)
.accessibilityHidden(true)
Text(account.selectedBlog?.name ?? account.username ?? "Connected")
.lineLimit(1)
.truncationMode(.tail)
Spacer()
SettingsLink {
Image(systemName: "gearshape")
}
.buttonStyle(.borderless)
.help("Tumblr account settings")
.accessibilityLabel("Tumblr account settings")
} else if account.isConnecting {
ProgressView()
.controlSize(.small)
Text("Waiting for browser…")
Spacer()
Button("Cancel") {
account.cancelConnect()
}
.buttonStyle(.borderless)
} else {
Image(systemName: "circle")
.font(.system(size: 8))
.foregroundStyle(.secondary)
.accessibilityHidden(true)
Text("Not connected")
.foregroundStyle(.secondary)
Spacer()
Button("Connect…") {
account.connect()
}
.buttonStyle(.borderless)
}
}
.font(.callout)
}
}
+54
View File
@@ -0,0 +1,54 @@
import AppKit
import SwiftUI
/// First-run screen: asks the user where the IJSS folder should live.
struct WelcomeView: View {
@Environment(ImageLibrary.self) private var library
var body: some View {
VStack(spacing: 16) {
Image(systemName: "photo.stack")
.font(.system(size: 56))
.foregroundStyle(.tint)
.accessibilityHidden(true)
Text("Welcome to Image Junky")
.font(.largeTitle.bold())
Text("Choose or create a folder to hold your images. IJSS will add a Tumblr folder inside it. Each folder you make in there becomes a tag for the images it contains.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.frame(maxWidth: 420)
Button("Choose IJSS Folder…") {
if let url = FolderPicker.chooseLibraryFolder() {
library.useFolder(url)
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
.padding(40)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
/// Shows the standard macOS Open panel for picking (or creating) the IJSS folder.
enum FolderPicker {
static func chooseLibraryFolder() -> URL? {
let panel = NSOpenPanel()
panel.message = "Choose a folder for IJSS. You can make a new one with the New Folder button."
panel.prompt = "Use This Folder"
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.canCreateDirectories = true
panel.allowsMultipleSelection = false
panel.directoryURL = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first
return panel.runModal() == .OK ? panel.url : nil
}
}
#Preview {
WelcomeView()
.environment(ImageLibrary())
}
+29
View File
@@ -0,0 +1,29 @@
# Image Junky Sharing System
## Initial Development Ideas and Directions
### Overview
The purpose of the Image Junky Sharing System is to automate the posting of images to various online services and provide an easy way to share these images with others. Since this system is focused on the sharing of images, I'd like this to be a macOS app with a helpful graphical user interface. Initially, this app would work with Tumblr but other services can be added later after the foundational work is more-or-less complete.
### Operation and Workflow
The use and operation of the app would mostly be a trigger to act upon image files in a given directory. There would be a primary directory for all services and, within that, separate directories for the various services. As an example there would be a IJSS directory, and then a directory within that called Tumblr, and within the Tumblr directory there would be other directories to handle the images. For the initial setup and testing with Tumblr, the names of the directories would correspond to a tag attached to the post. So an image in a directory called Cyberpunk would post to a Tumblr blog with Cyberpunk as the tag.
My initial thought on the workflow would be something like this:
1. The user opens up the app.
2. If this is the first time the user has opened the app, they will need to connect their Tumblr blog. I'm not sure of the best way to do this but if the user could be taken to a web browser where they'd log into Tumblr and the app picked up a connection that way, that'd be good. If not, I can write documentation on how to get an API key for their Tumblr account.
3. Once the app has successfully connected, the user can use the app to add folders to the Tumblr directory, or they can set them up manually in Finder.
4. The user can use the app to add images to these folders, or they can set that up manually in Finder too.
5. The user then clicks a button in the app that scans the Tumblr directory, checks for new folders and images, and begins posting the items to the Tumblr queue in the background. We could change this later on to where the items post directly to the Tumblr blog, but I think it'd be a good idea to start with the queue first. The posts should have a courtesy delay so the user doesn't get hit with a timeout or anything like that.
6. After the posting completes, provide a dialogue box to confirm posting or provide any failure or error notifications.
7. The successfully posted images are removed from the posting directory and added to a database the user can use to see if they've already posted the same image before. A SQLite database should work for this.
8. There should be some way for the user to clear the database if they want to. That should clean up all the posted images too, sending them to the Recycle Bin or deleting them outright.
### Further Considerations
* I know almost nothing about writing code in Xcode for macOS, so take that into consideration throughout the process.
* I don't think I'd want to submit this to the Mac App Store, but I might like to *eventually* sign it and have it notarized by Apple so it'll run more easily for anyone who wants to download it and use it. That's not something I care to do right away, especially during the initial building and testing phase, but it'd be something to keep in mind for later.
* Because I might want to seek Apple notarization, the app will need to conform to standard macOS workflows, best practices, and handle things like application support and file storage in the correct manner.
+32
View File
@@ -0,0 +1,32 @@
# IJSS To Do
A running list of what's still open. Add notes from day-to-day use under **Notes from Testing**.
## Planned Features
- [ ] **Caption setting** — Optional caption text for posts (currently image-only with the folder name as the tag).
- [ ] **More services** — Add other sites alongside Tumblr (the folder layout already allows one folder per service).
## Before Sharing the App
- [ ] **Signing team** — Choose a team under *Signing & Capabilities* in Xcode. This also stops the Keychain "allow access" prompts after rebuilds.
- [ ] **Hardened Runtime** — Turn on in *Signing & Capabilities* (required for notarization).
- [ ] **Developer ID + notarization** — Needs a paid Apple Developer account. Export with *Product ▸ Archive ▸ Distribute App ▸ Direct Distribution*.
- [ ] **Credentials setup for a fresh clone** — `TumblrCredentials.plist` is ignored by Git, so a new checkout won't have it. Add setup instructions to the README (or an example file) explaining how to create it with the OAuth Consumer Key and Secret Key.
- [ ] **Client secret in the app** — The Tumblr secret ships inside the app bundle. Acceptable for now; revisit before a public release (e.g., obfuscation or a small relay server).
- [ ] **App icon** — `Assets.xcassets` has no icon yet.
## Known Limitations
- [ ] **Daily limit counting** — The 250-posts-per-day check counts from local midnight, but Tumblr uses its own clock, so the count can be slightly off near midnight. Tumblr still stops posting if the real limit is hit.
- [ ] **Tumblr queue size** — Tumblr's queue holds about 300 posts. IJSS doesn't check how full the queue is before posting; it stops after 3 failures in a row instead.
- [ ] **WebP uploads** — Allowed, but not confirmed by Tumblr's docs (which list JPG, GIF, and PNG). Test a WebP file.
- [ ] **Unsupported formats** — HEIC, TIFF, and similar files are skipped. Could convert them to JPEG automatically before uploading.
- [ ] **Quitting while posting** — Quitting mid-run stops posting without a warning. Images already posted are recorded and archived, so nothing is posted twice, but a "posting is in progress" warning would be friendlier.
- [ ] **Login port** — Login uses `http://localhost:8765/oauth-callback`. If another app ever uses port 8765, the port must be changed in `TumblrCredentials.swift` *and* in the app's settings on tumblr.com/oauth/apps.
## Notes from Testing
<!-- Add changes, bugs, and feature ideas here as you use the app. -->
-