diff --git a/Foundation/CPError.j b/Foundation/CPError.j index 8e120ca9d..d4fcb1cc2 100644 --- a/Foundation/CPError.j +++ b/Foundation/CPError.j @@ -40,6 +40,8 @@ CPStringEncodingErrorKey = @"CPStringEncodingErrorKey"; CPURLErrorKey = kCFErrorURLKey; CPFilePathErrorKey = kCFErrorFilePathKey; +CPUserCancelledError = 3072; + /*! @class CPError @ingroup foundation diff --git a/Foundation/CPItemProvider.j b/Foundation/CPItemProvider.j index 70bde3f43..db56e70e2 100644 --- a/Foundation/CPItemProvider.j +++ b/Foundation/CPItemProvider.j @@ -25,8 +25,10 @@ @import "CPData.j" @import "CPDictionary.j" @import "CPError.j" +@import "CPProgress.j" @import "CPRunLoop.j" @import "CPString.j" +@import "CPString+Encoding.j" @global FileReader @@ -36,18 +38,35 @@ CPItemProviderUnknownError = -1; CPItemProviderItemUnavailableError = -1000; CPItemProviderUnexpectedValueClassError = -1100; +var CPItemProviderChunkSize = 32768; + +/* + Returns a binary string for an array buffer: one character for each byte, of the same value. + The buffer is converted in chunks, because a very long argument list exceeds the JavaScript + engine's limit. + + CPData holds a binary string compactly, one byte for each character in current JavaScript + engines. Its byte array would hold one JavaScript number for each byte, many times the size, + because CFData stores bytes that way. File contents are given to CPData as a binary string. +*/ +var CPItemProviderBinaryStringFromArrayBuffer = function(anArrayBuffer) +{ + var bytes = new Uint8Array(anArrayBuffer), + chunks = []; + + for (var i = 0, count = bytes.length; i < count; i += CPItemProviderChunkSize) + chunks.push(String.fromCharCode.apply(null, bytes.subarray(i, i + CPItemProviderChunkSize))); + + return chunks.join(""); +}; + /* TODO: Gaps against NSItemProvider that can be filled: - - -loadDataRepresentationForTypeIdentifier:completionHandler: returns an NSProgress in Cocoa, - which reports reading progress and cancels the load. Foundation has no CPProgress yet. - Cancelling a file read would call FileReader's abort(). - - Object loading: -canLoadObjectOfClass:, -loadObjectOfClass:completionHandler:, - -registerObject:visibility:, -initWithObject: and -initWithItem:typeIdentifier:, with the - reading and writing protocols. Loading a CPString this way could decode the browser's - buffer directly with TextDecoder. - - Files are read into a plain JavaScript array with one number per byte, because CFData - stores bytes that way. A large file needs many times its own size in memory. + - Writing: -registerObject:visibility:, -registerObjectOfClass:visibility:loadHandler:, + -initWithObject: and -initWithItem:typeIdentifier:, with a writing protocol. + - Loading a large file still holds it several times over at its peak: the browser's + buffer, the binary string in CPData, and the decoded text. */ /*! @@ -73,15 +92,21 @@ CPItemProviderUnexpectedValueClassError = -1100; - Representations have no visibility. A page is one process, so Cocoa's visibility levels have no meaning. + Each load returns a CPProgress. For a file, it counts the bytes read. Cancelling it stops + the read, and the completion handler then receives a CPUserCancelledError. + A completion handler receives the data, or nil and an error:
- [anItemProvider loadDataRepresentationForTypeIdentifier:@"image/*" completionHandler:function(data, error)
+ var progress = [anItemProvider loadDataRepresentationForTypeIdentifier:@"image/*" completionHandler:function(data, error)
{
if (data)
[self useData:data];
}];
+
+ An object of a class that adopts the item provider reading methods, such as CPString, can
+ be loaded with -loadObjectOfClass:completionHandler:.
*/
@implementation CPItemProvider : CPObject
{
@@ -128,7 +153,7 @@ CPItemProviderUnexpectedValueClassError = -1100;
[itemProvider setSuggestedName:aFile.webkitRelativePath || aFile.name];
[itemProvider registerDataRepresentationForTypeIdentifier:typeIdentifier loadHandler:function(completionHandler)
{
- [CPItemProvider _readFile:aFile completionHandler:completionHandler];
+ return [CPItemProvider _readFile:aFile completionHandler:completionHandler];
}];
return itemProvider;
@@ -151,22 +176,37 @@ CPItemProviderUnexpectedValueClassError = -1100;
/*!
Reads a browser File object with FileReader, and calls \c aCompletionHandler with its
- contents as data, or with nil and an error.
+ contents as data, or with nil and an error. Returns a progress object that counts the bytes
+ read. Cancelling it aborts the read.
@ignore
*/
-+ (void)_readFile:(File)aFile completionHandler:(Function)aCompletionHandler
++ (CPProgress)_readFile:(File)aFile completionHandler:(Function)aCompletionHandler
{
+ var progress = [CPProgress discreteProgressWithTotalUnitCount:MAX(aFile.size || 0, 1)];
+
+ [progress setKind:CPProgressKindFile];
+ [progress setFileOperationKind:CPProgressFileOperationKindReceiving];
+
if (typeof FileReader === "undefined")
{
aCompletionHandler(nil, [self _errorWithCode:CPItemProviderItemUnavailableError description:@"Files cannot be read in this environment."]);
- return;
+ return progress;
}
var reader = new FileReader();
+ reader.onprogress = function(anEvent)
+ {
+ if (anEvent.lengthComputable)
+ [progress setCompletedUnitCount:MIN(anEvent.loaded, [progress totalUnitCount] - 1)];
+ };
+
reader.onload = function()
{
- aCompletionHandler([CPData dataWithBytes:Array.from(new Uint8Array(reader.result))], nil);
+ var data = [CPData dataWithRawString:CPItemProviderBinaryStringFromArrayBuffer(reader.result)];
+
+ [progress setCompletedUnitCount:[progress totalUnitCount]];
+ aCompletionHandler(data, nil);
};
reader.onerror = function()
@@ -179,7 +219,20 @@ CPItemProviderUnexpectedValueClassError = -1100;
aCompletionHandler(nil, [self _errorWithCode:CPItemProviderUnknownError description:description]);
};
+ reader.onabort = function()
+ {
+ aCompletionHandler(nil, [self _cancelledError]);
+ };
+
+ [progress setCancellationHandler:function()
+ {
+ if (reader.readyState === 1)
+ reader.abort();
+ }];
+
reader.readAsArrayBuffer(aFile);
+
+ return progress;
}
/*!
@@ -192,6 +245,16 @@ CPItemProviderUnexpectedValueClassError = -1100;
userInfo:@{ CPLocalizedDescriptionKey: aDescription }];
}
+/*!
+ @ignore
+*/
++ (CPError)_cancelledError
+{
+ return [CPError errorWithDomain:CPCocoaErrorDomain
+ code:CPUserCancelledError
+ userInfo:@{ CPLocalizedDescriptionKey: @"The operation was cancelled." }];
+}
+
/*!
Initializes an item provider with no representations.
*/
@@ -215,10 +278,12 @@ CPItemProviderUnexpectedValueClassError = -1100;
The load handler is called with one argument, a completion handler. It must call that
completion handler once, with the data, or with nil and an error. It may do so at once, or
- later.
+ later. It may return a CPProgress for its work; the progress object returned by
+ -loadDataRepresentationForTypeIdentifier:completionHandler: then follows it, and cancelling
+ that progress object cancels it.
@param aTypeIdentifier a MIME type, such as "text/csv"
- @param aLoadHandler a function that takes a completion handler
+ @param aLoadHandler a function that takes a completion handler, and may return a CPProgress
*/
- (void)registerDataRepresentationForTypeIdentifier:(CPString)aTypeIdentifier loadHandler:(Function /*(Function completionHandler)*/)aLoadHandler
{
@@ -276,14 +341,17 @@ CPItemProviderUnexpectedValueClassError = -1100;
/*!
Loads the first registered representation that matches \c aTypeIdentifier, which may be a
wildcard of the form "image/*". The completion handler is called once, inside a run loop
- pass, with the data and nil, or with nil and an error.
+ pass, with the data and nil, or with nil and an error. When the returned progress object is
+ cancelled first, the error is a CPUserCancelledError.
@param aTypeIdentifier the type identifier to load
@param aCompletionHandler a function that takes the data and an error
+ @return a progress object for the load
*/
-- (void)loadDataRepresentationForTypeIdentifier:(CPString)aTypeIdentifier completionHandler:(Function /*(CPData data, CPError error)*/)aCompletionHandler
+- (CPProgress)loadDataRepresentationForTypeIdentifier:(CPString)aTypeIdentifier completionHandler:(Function /*(CPData data, CPError error)*/)aCompletionHandler
{
var registeredTypeIdentifier = [self _registeredTypeIdentifierConformingToTypeIdentifier:aTypeIdentifier],
+ progress = [CPProgress discreteProgressWithTotalUnitCount:1],
didComplete = NO,
complete = function(data, error)
{
@@ -292,8 +360,19 @@ CPItemProviderUnexpectedValueClassError = -1100;
didComplete = YES;
+ if ([progress isCancelled])
+ {
+ data = nil;
+ error = [CPItemProvider _cancelledError];
+ }
+ else if (!data && !error)
+ error = [CPItemProvider _errorWithCode:CPItemProviderUnknownError description:@"The item could not be loaded."];
+
+ if (data && ![progress isFinished])
+ [progress setCompletedUnitCount:1];
+
if (aCompletionHandler)
- aCompletionHandler(data || nil, data ? nil : error || [CPItemProvider _errorWithCode:CPItemProviderUnknownError description:@"The item could not be loaded."]);
+ aCompletionHandler(data || nil, data ? nil : error);
[[CPRunLoop currentRunLoop] limitDateForMode:CPDefaultRunLoopMode];
};
@@ -301,21 +380,94 @@ CPItemProviderUnexpectedValueClassError = -1100;
if (!registeredTypeIdentifier)
{
complete(nil, [CPItemProvider _errorWithCode:CPItemProviderItemUnavailableError description:@"The item has no representation of type " + aTypeIdentifier + @"."]);
- return;
+ return progress;
}
- var loadHandler = [_loadHandlers objectForKey:registeredTypeIdentifier];
-
- loadHandler(function(data, error)
- {
- if (data && ![data isKindOfClass:[CPData class]])
+ var loadHandler = [_loadHandlers objectForKey:registeredTypeIdentifier],
+ loadProgress = loadHandler(function(data, error)
{
- complete(nil, [CPItemProvider _errorWithCode:CPItemProviderUnexpectedValueClassError description:@"The item's load handler returned an object that is not data."]);
+ if (data && ![data isKindOfClass:[CPData class]])
+ {
+ complete(nil, [CPItemProvider _errorWithCode:CPItemProviderUnexpectedValueClassError description:@"The item's load handler returned an object that is not data."]);
+ return;
+ }
+
+ complete(data, error);
+ });
+
+ if (loadProgress && [loadProgress isKindOfClass:[CPProgress class]] && !didComplete)
+ [progress addChild:loadProgress withPendingUnitCount:1];
+
+ return progress;
+}
+
+// MARK: Loading Objects
+
+/*!
+ Returns YES if an object of \c aClass can be loaded from the item. \c aClass must implement
+ +readableTypeIdentifiersForItemProvider, and the item must have a representation of one of
+ those types.
+*/
+- (BOOL)canLoadObjectOfClass:(Class)aClass
+{
+ return [self _typeIdentifierReadableByClass:aClass] !== nil;
+}
+
+/*!
+ Loads an object of \c aClass from the item. The first representation of a type that the
+ class can read is loaded, and given to +objectWithItemProviderData:typeIdentifier:error:.
+ The completion handler is called once, inside a run loop pass, with the object and nil, or
+ with nil and an error.
+
+ @param aClass a class that implements the item provider reading methods, such as CPString
+ @param aCompletionHandler a function that takes the object and an error
+ @return a progress object for the load
+*/
+- (CPProgress)loadObjectOfClass:(Class)aClass completionHandler:(Function /*(id object, CPError error)*/)aCompletionHandler
+{
+ var typeIdentifier = [self _typeIdentifierReadableByClass:aClass];
+
+ if (!typeIdentifier)
+ return [self loadDataRepresentationForTypeIdentifier:@"" completionHandler:function(data, error)
+ {
+ aCompletionHandler(nil, [CPItemProvider _errorWithCode:CPItemProviderItemUnavailableError description:@"The item has no representation that " + [aClass className] + @" can read."]);
+ }];
+
+ return [self loadDataRepresentationForTypeIdentifier:typeIdentifier completionHandler:function(data, error)
+ {
+ if (!data)
+ {
+ aCompletionHandler(nil, error);
return;
}
- complete(data, error);
- });
+ var readError = nil,
+ object = [aClass objectWithItemProviderData:data typeIdentifier:typeIdentifier error:@ref(readError)];
+
+ if (object === nil || object === undefined)
+ aCompletionHandler(nil, readError || [CPItemProvider _errorWithCode:CPItemProviderUnexpectedValueClassError description:@"The item could not be read as " + [aClass className] + @"."]);
+ else
+ aCompletionHandler(object, nil);
+ }];
+}
+
+/*!
+ Returns the first registered type identifier that \c aClass can read, or nil.
+ @ignore
+*/
+- (CPString)_typeIdentifierReadableByClass:(Class)aClass
+{
+ if (![aClass respondsToSelector:@selector(readableTypeIdentifiersForItemProvider)])
+ return nil;
+
+ var readableTypeIdentifiers = [aClass readableTypeIdentifiersForItemProvider];
+
+ for (var i = 0, count = [_registeredTypeIdentifiers count]; i < count; i++)
+ for (var j = 0, readableCount = [readableTypeIdentifiers count]; j < readableCount; j++)
+ if ([CPItemProvider _typeIdentifier:_registeredTypeIdentifiers[i] conformsToTypeIdentifier:readableTypeIdentifiers[j]])
+ return _registeredTypeIdentifiers[i];
+
+ return nil;
}
/*!
@@ -327,3 +479,43 @@ CPItemProviderUnexpectedValueClassError = -1100;
}
@end
+
+/*!
+ CPString reads text from an item provider. The encoding is detected, as by
+ +stringEncodingForData:encodingOptions:convertedString:usedLossyConversion:.
+
+ Browsers report some text formats under other MIME types; for example, a CSV file may be
+ reported as "application/vnd.ms-excel" on Windows. Such an item is not readable as a string
+ by this method, but its data can be loaded and decoded directly.
+*/
+@implementation CPString (CPItemProvider)
+
+/*!
+ Returns the MIME types a string can be read from: every text type, JSON, XML and JavaScript.
+*/
++ (CPArray)readableTypeIdentifiersForItemProvider
+{
+ return [@"text/*", @"application/json", @"application/xml", @"application/javascript"];
+}
+
+/*!
+ Returns a string decoded from an item provider's data, or nil with an error when no
+ encoding fits the data.
+*/
++ (id)objectWithItemProviderData:(CPData)someData typeIdentifier:(CPString)aTypeIdentifier error:(CPErrorRef)anErrorRef
+{
+ var string = nil,
+ encoding = [CPString stringEncodingForData:someData encodingOptions:nil convertedString:@ref(string) usedLossyConversion:nil];
+
+ if (encoding)
+ return string;
+
+ if (anErrorRef)
+ @deref(anErrorRef) = [CPError errorWithDomain:CPItemProviderErrorDomain
+ code:CPItemProviderUnexpectedValueClassError
+ userInfo:@{ CPLocalizedDescriptionKey: @"The item's data is not text in a known encoding." }];
+
+ return nil;
+}
+
+@end
diff --git a/Foundation/CPProgress.j b/Foundation/CPProgress.j
new file mode 100644
index 000000000..b4d27ca7f
--- /dev/null
+++ b/Foundation/CPProgress.j
@@ -0,0 +1,683 @@
+/*
+ * CPProgress.j
+ * Foundation
+ *
+ * Created by David Richardson on 2026-11-02.
+ * Copyright David Richardson 2026.
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+@import "CPArray.j"
+@import "CPDictionary.j"
+@import "CPException.j"
+@import "CPKeyValueObserving.j"
+@import "CPObject.j"
+@import "CPSet.j"
+@import "CPString.j"
+
+CPProgressKindFile = @"CPProgressKindFile";
+
+CPProgressEstimatedTimeRemainingKey = @"CPProgressEstimatedTimeRemainingKey";
+CPProgressThroughputKey = @"CPProgressThroughputKey";
+CPProgressFileOperationKindKey = @"CPProgressFileOperationKindKey";
+CPProgressFileURLKey = @"CPProgressFileURLKey";
+CPProgressFileTotalCountKey = @"CPProgressFileTotalCountKey";
+CPProgressFileCompletedCountKey = @"CPProgressFileCompletedCountKey";
+
+CPProgressFileOperationKindDownloading = @"CPProgressFileOperationKindDownloading";
+CPProgressFileOperationKindDecompressingAfterDownloading = @"CPProgressFileOperationKindDecompressingAfterDownloading";
+CPProgressFileOperationKindReceiving = @"CPProgressFileOperationKindReceiving";
+CPProgressFileOperationKindCopying = @"CPProgressFileOperationKindCopying";
+CPProgressFileOperationKindUploading = @"CPProgressFileOperationKindUploading";
+CPProgressFileOperationKindDuplicating = @"CPProgressFileOperationKindDuplicating";
+
+/*
+ The keys whose values follow from the unit counts of a progress object and of its children.
+ They change together, so they are announced together. See -_performCountChange:.
+*/
+var CPProgressCountKeys = [@"totalUnitCount", @"completedUnitCount", @"fractionCompleted", @"finished", @"indeterminate", @"localizedDescription", @"localizedAdditionalDescription"],
+ CPProgressManuallyNotifiedKeys = [CPSet setWithArray:CPProgressCountKeys.concat([@"cancelled", @"paused", @"cancellable", @"pausable", @"kind", @"userInfo"])],
+ CPProgressCurrentStack = [];
+
+/*
+ TODO: Gaps against NSProgress that can be filled:
+
+ - The fileURL, fileTotalCount and fileCompletedCount properties. Only their user
+ information keys exist.
+ - The NSProgressReporting protocol, with its progress property.
+ - The default descriptions. Cocoa's depend on the kind, and its additional description
+ includes the estimated time remaining and the throughput. These are the percentage and
+ the unit counts only.
+ - The file icon and file animation user information keys.
+ - Setting a cancellation or pausing handler on a progress object that is already cancelled
+ or paused. Check whether Cocoa calls the handler at once; this class does not.
+ - Key-value observing. Cocoa announces only the keys that change; this class announces
+ every unit-count key of each progress object in the chain.
+*/
+
+/*!
+ @class CPProgress
+ @ingroup foundation
+ @brief Reports the progress of a task, and lets the user cancel or pause it.
+
+ This class is modelled on NSProgress. A progress object counts units of work: a total, and
+ the units completed. Its fraction completed can be observed with key-value observing, and
+ shown in a progress indicator.
+
+ Progress objects form a tree. A child progress object stands for a number of its parent's
+ units, its pending unit count. As the child advances, the parent's fraction completed
+ advances in proportion. When the child finishes, its pending units are added to the
+ parent's completed units. A child is attached explicitly, with
+ -addChild:withPendingUnitCount:, or implicitly: a progress object made while its parent is
+ the current progress object, between -becomeCurrentWithPendingUnitCount: and
+ -resignCurrent, becomes its child.
+
+ Cancelling or pausing a progress object also cancels or pauses its children, and calls its
+ handler. A task checks -isCancelled, or sets a cancellation handler, to stop its work.
+
+ In a browser there is one thread, so handlers are called at once, on that thread. The
+ unit-count keys are announced together whenever a count changes in the tree, so an
+ observer may receive a notification for a key whose value did not change.
+
+ Publishing and subscribing (-publish, -unpublish, the subscriber methods and -isOld) are
+ not provided. They share file progress between processes, and a page is one process.
+*/
+@implementation CPProgress : CPObject
+{
+ CPProgress _parent;
+ CPArray _children;
+
+ int _totalUnitCount;
+ int _completedUnitCount;
+
+ BOOL _cancellable;
+ BOOL _pausable;
+ BOOL _cancelled;
+ BOOL _paused;
+
+ Function _cancellationHandler @accessors(property=cancellationHandler);
+ Function _pausingHandler @accessors(property=pausingHandler);
+ Function _resumingHandler @accessors(property=resumingHandler);
+
+ CPString _localizedDescription;
+ CPString _localizedAdditionalDescription;
+ CPString _kind;
+ CPMutableDictionary _userInfo;
+}
+
++ (BOOL)automaticallyNotifiesObserversForKey:(CPString)aKey
+{
+ if ([CPProgressManuallyNotifiedKeys containsObject:aKey])
+ return NO;
+
+ return [super automaticallyNotifiesObserversForKey:aKey];
+}
+
+// MARK: Creating Progress Objects
+
+/*!
+ Returns the progress object that is current, or nil. See -becomeCurrentWithPendingUnitCount:.
+*/
++ (CPProgress)currentProgress
+{
+ var entry = CPProgressCurrentStack[CPProgressCurrentStack.length - 1];
+
+ return entry ? entry.progress : nil;
+}
+
+/*!
+ Returns a progress object with a total unit count. If a progress object is current, the new
+ one becomes its child.
+*/
++ (CPProgress)progressWithTotalUnitCount:(int)aCount
+{
+ var progress = [[self alloc] initWithParent:[self currentProgress] userInfo:nil];
+
+ [progress setTotalUnitCount:aCount];
+
+ return progress;
+}
+
+/*!
+ Returns a progress object with a total unit count and no parent, even if a progress object
+ is current.
+*/
++ (CPProgress)discreteProgressWithTotalUnitCount:(int)aCount
+{
+ var progress = [[self alloc] initWithParent:nil userInfo:nil];
+
+ [progress setTotalUnitCount:aCount];
+
+ return progress;
+}
+
+/*!
+ Returns a progress object with a total unit count, attached to \c aParent as a child that
+ stands for \c aPendingUnitCount of its units.
+*/
++ (CPProgress)progressWithTotalUnitCount:(int)aCount parent:(CPProgress)aParent pendingUnitCount:(int)aPendingUnitCount
+{
+ var progress = [self discreteProgressWithTotalUnitCount:aCount];
+
+ [aParent addChild:progress withPendingUnitCount:aPendingUnitCount];
+
+ return progress;
+}
+
+/*!
+ Initializes a progress object. If a progress object is current, the new one becomes its
+ child.
+*/
+- (id)init
+{
+ return [self initWithParent:[CPProgress currentProgress] userInfo:nil];
+}
+
+/*!
+ Initializes a progress object. \c aParent must be nil or the current progress object; the
+ new progress object becomes its child, standing for the pending unit count given to
+ -becomeCurrentWithPendingUnitCount:. Only the first progress object made while a parent is
+ current becomes its child.
+
+ @throws CPInvalidArgumentException if \c aParent is not nil and not current
+*/
+- (id)initWithParent:(CPProgress)aParent userInfo:(CPDictionary)aUserInfo
+{
+ if (self = [super init])
+ {
+ _children = [];
+ _totalUnitCount = 0;
+ _completedUnitCount = 0;
+ _cancellable = YES;
+ _pausable = NO;
+ _cancelled = NO;
+ _paused = NO;
+ _userInfo = aUserInfo ? [aUserInfo mutableCopy] : [CPMutableDictionary dictionary];
+
+ if (aParent)
+ {
+ var entry = CPProgressCurrentStack[CPProgressCurrentStack.length - 1];
+
+ if (!entry || entry.progress !== aParent)
+ [CPException raise:CPInvalidArgumentException reason:@"The parent of a progress object must be nil or the current progress object."];
+
+ if (!entry.hasAttachedChild)
+ {
+ entry.hasAttachedChild = YES;
+ [aParent addChild:self withPendingUnitCount:entry.pendingUnitCount];
+ }
+ }
+ }
+
+ return self;
+}
+
+// MARK: The Current Progress Object
+
+/*!
+ Makes the receiver the current progress object. The first progress object made before
+ -resignCurrent becomes its child, standing for \c aPendingUnitCount of its units.
+*/
+- (void)becomeCurrentWithPendingUnitCount:(int)aPendingUnitCount
+{
+ CPProgressCurrentStack.push({ progress: self, pendingUnitCount: aPendingUnitCount, hasAttachedChild: NO });
+}
+
+/*!
+ Ends the receiver's time as the current progress object. If no child was made meanwhile,
+ the pending units are completed.
+
+ @throws CPInternalInconsistencyException if the receiver is not current
+*/
+- (void)resignCurrent
+{
+ var entry = CPProgressCurrentStack[CPProgressCurrentStack.length - 1];
+
+ if (!entry || entry.progress !== self)
+ [CPException raise:CPInternalInconsistencyException reason:@"-resignCurrent was sent to a progress object that is not current."];
+
+ CPProgressCurrentStack.pop();
+
+ if (!entry.hasAttachedChild)
+ [self setCompletedUnitCount:_completedUnitCount + entry.pendingUnitCount];
+}
+
+/*!
+ Makes the receiver current, calls \c aBlock, and resigns.
+*/
+- (void)performAsCurrentWithPendingUnitCount:(int)aPendingUnitCount usingBlock:(Function)aBlock
+{
+ [self becomeCurrentWithPendingUnitCount:aPendingUnitCount];
+
+ try
+ {
+ aBlock();
+ }
+ finally
+ {
+ [self resignCurrent];
+ }
+}
+
+// MARK: Children
+
+/*!
+ Attaches \c aChild, which stands for \c aPendingUnitCount of the receiver's units. A child
+ attached to a cancelled or paused progress object is cancelled or paused at once.
+
+ @throws CPInvalidArgumentException if \c aChild already has a parent
+*/
+- (void)addChild:(CPProgress)aChild withPendingUnitCount:(int)aPendingUnitCount
+{
+ if (aChild._parent)
+ [CPException raise:CPInvalidArgumentException reason:@"A progress object can have only one parent."];
+
+ [self _performCountChange:function()
+ {
+ aChild._parent = self;
+ _children.push({ progress: aChild, pendingUnitCount: aPendingUnitCount });
+ [aChild _finishIfComplete];
+ }];
+
+ if (_cancelled)
+ [aChild cancel];
+
+ if (_paused)
+ [aChild pause];
+}
+
+// MARK: Unit Counts
+
+/*!
+ Returns the total number of units of work.
+*/
+- (int)totalUnitCount
+{
+ return _totalUnitCount;
+}
+
+/*!
+ Sets the total number of units of work. A negative total makes the progress indeterminate.
+*/
+- (void)setTotalUnitCount:(int)aCount
+{
+ if (aCount === _totalUnitCount)
+ return;
+
+ [self _performCountChange:function()
+ {
+ _totalUnitCount = aCount;
+ [self _finishIfComplete];
+ }];
+}
+
+/*!
+ Returns the number of units of work completed.
+*/
+- (int)completedUnitCount
+{
+ return _completedUnitCount;
+}
+
+/*!
+ Sets the number of units of work completed. When it reaches the total, a parent completes
+ the units this progress object stands for.
+*/
+- (void)setCompletedUnitCount:(int)aCount
+{
+ if (aCount === _completedUnitCount)
+ return;
+
+ [self _performCountChange:function()
+ {
+ _completedUnitCount = aCount;
+ [self _finishIfComplete];
+ }];
+}
+
+/*!
+ Returns the fraction of the work completed, including the progress of children that have
+ not finished, from 0.0. It is 0.0 while the progress is indeterminate.
+*/
+- (double)fractionCompleted
+{
+ if (_totalUnitCount <= 0)
+ return 0.0;
+
+ var units = _completedUnitCount;
+
+ for (var i = 0; i < _children.length; i++)
+ units += [_children[i].progress fractionCompleted] * _children[i].pendingUnitCount;
+
+ return units / _totalUnitCount;
+}
+
+/*!
+ Returns YES when the work is complete: the total is positive, and the completed units have
+ reached it.
+*/
+- (BOOL)isFinished
+{
+ return _totalUnitCount > 0 && _completedUnitCount >= _totalUnitCount;
+}
+
+/*!
+ Returns YES when the amount of work is unknown: either count is negative, or both are 0.
+*/
+- (BOOL)isIndeterminate
+{
+ return _totalUnitCount < 0 || _completedUnitCount < 0 || (_totalUnitCount === 0 && _completedUnitCount === 0);
+}
+
+/*
+ Changes unit counts in the tree. Every count key of the receiver and of each of its
+ ancestors is announced before and after the change, so that an observer of any of them sees
+ a consistent value. A parent's counts and fraction depend on its children, so a change
+ anywhere below reaches every ancestor.
+*/
+- (void)_performCountChange:(Function)aChange
+{
+ var chain = [];
+
+ for (var progress = self; progress; progress = progress._parent)
+ chain.push(progress);
+
+ for (var i = 0; i < chain.length; i++)
+ for (var j = 0; j < CPProgressCountKeys.length; j++)
+ [chain[i] willChangeValueForKey:CPProgressCountKeys[j]];
+
+ aChange();
+
+ for (var i = chain.length - 1; i >= 0; i--)
+ for (var j = CPProgressCountKeys.length - 1; j >= 0; j--)
+ [chain[i] didChangeValueForKey:CPProgressCountKeys[j]];
+}
+
+/*
+ When the receiver has finished, its parent removes it and completes the units it stood for.
+ That may finish the parent in turn. Called inside -_performCountChange:, which has already
+ announced the change for every ancestor.
+*/
+- (void)_finishIfComplete
+{
+ var parent = _parent;
+
+ if (![self isFinished] || !parent)
+ return;
+
+ for (var i = 0; i < parent._children.length; i++)
+ {
+ var entry = parent._children[i];
+
+ if (entry.progress !== self)
+ continue;
+
+ parent._children.splice(i, 1);
+ parent._completedUnitCount += entry.pendingUnitCount;
+ _parent = nil;
+
+ [parent _finishIfComplete];
+ return;
+ }
+}
+
+// MARK: Cancelling and Pausing
+
+/*!
+ Returns YES if the user may cancel the work. This is a hint for a user interface: -cancel
+ works either way. The default is YES.
+*/
+- (BOOL)isCancellable
+{
+ return _cancellable;
+}
+
+- (void)setCancellable:(BOOL)isCancellable
+{
+ [self _setValue:!!isCancellable forIvar:@"_cancellable" key:@"cancellable"];
+}
+
+/*!
+ Returns YES if the user may pause the work. This is a hint for a user interface. The
+ default is NO.
+*/
+- (BOOL)isPausable
+{
+ return _pausable;
+}
+
+- (void)setPausable:(BOOL)isPausable
+{
+ [self _setValue:!!isPausable forIvar:@"_pausable" key:@"pausable"];
+}
+
+/*!
+ Returns YES once the work has been cancelled.
+*/
+- (BOOL)isCancelled
+{
+ return _cancelled;
+}
+
+/*!
+ Returns YES while the work is paused.
+*/
+- (BOOL)isPaused
+{
+ return _paused;
+}
+
+/*!
+ Cancels the work: marks the receiver cancelled, calls its cancellation handler, and cancels
+ its children. Cancelling again does nothing.
+*/
+- (void)cancel
+{
+ if (_cancelled)
+ return;
+
+ [self _setValue:YES forIvar:@"_cancelled" key:@"cancelled"];
+
+ if (_cancellationHandler)
+ _cancellationHandler();
+
+ var children = _children.slice();
+
+ for (var i = 0; i < children.length; i++)
+ [children[i].progress cancel];
+}
+
+/*!
+ Pauses the work: marks the receiver paused, calls its pausing handler, and pauses its
+ children.
+*/
+- (void)pause
+{
+ if (_paused)
+ return;
+
+ [self _setValue:YES forIvar:@"_paused" key:@"paused"];
+
+ if (_pausingHandler)
+ _pausingHandler();
+
+ var children = _children.slice();
+
+ for (var i = 0; i < children.length; i++)
+ [children[i].progress pause];
+}
+
+/*!
+ Resumes paused work: marks the receiver not paused, calls its resuming handler, and resumes
+ its children.
+*/
+- (void)resume
+{
+ if (!_paused)
+ return;
+
+ [self _setValue:NO forIvar:@"_paused" key:@"paused"];
+
+ if (_resumingHandler)
+ _resumingHandler();
+
+ var children = _children.slice();
+
+ for (var i = 0; i < children.length; i++)
+ [children[i].progress resume];
+}
+
+// MARK: Descriptions
+
+/*!
+ Returns a description of the progress. Unless one has been set, it is the percentage
+ completed, such as "42% completed".
+*/
+- (CPString)localizedDescription
+{
+ if (_localizedDescription !== nil && _localizedDescription !== undefined)
+ return _localizedDescription;
+
+ return ROUND([self fractionCompleted] * 100) + @"% completed";
+}
+
+- (void)setLocalizedDescription:(CPString)aDescription
+{
+ [self _setValue:aDescription forIvar:@"_localizedDescription" key:@"localizedDescription"];
+}
+
+/*!
+ Returns more detail about the progress. Unless one has been set, it is the units completed
+ and the total, such as "42 of 100".
+*/
+- (CPString)localizedAdditionalDescription
+{
+ if (_localizedAdditionalDescription !== nil && _localizedAdditionalDescription !== undefined)
+ return _localizedAdditionalDescription;
+
+ if ([self isIndeterminate])
+ return @"";
+
+ return _completedUnitCount + @" of " + _totalUnitCount;
+}
+
+- (void)setLocalizedAdditionalDescription:(CPString)aDescription
+{
+ [self _setValue:aDescription forIvar:@"_localizedAdditionalDescription" key:@"localizedAdditionalDescription"];
+}
+
+/*!
+ Returns the kind of progress, such as CPProgressKindFile, or nil.
+*/
+- (CPString)kind
+{
+ return _kind;
+}
+
+- (void)setKind:(CPString)aKind
+{
+ [self _setValue:aKind forIvar:@"_kind" key:@"kind"];
+}
+
+// MARK: User Information
+
+/*!
+ Returns a copy of the receiver's user information.
+*/
+- (CPDictionary)userInfo
+{
+ return [_userInfo copy];
+}
+
+/*!
+ Sets a value in the receiver's user information, or removes it when \c anObject is nil.
+*/
+- (void)setUserInfoObject:(id)anObject forKey:(CPString)aKey
+{
+ [self willChangeValueForKey:@"userInfo"];
+
+ if (anObject === nil || anObject === undefined)
+ [_userInfo removeObjectForKey:aKey];
+ else
+ [_userInfo setObject:anObject forKey:aKey];
+
+ [self didChangeValueForKey:@"userInfo"];
+}
+
+/*!
+ Returns the estimated time remaining, in seconds, or nil. It is stored in the user
+ information under CPProgressEstimatedTimeRemainingKey.
+*/
+- (CPNumber)estimatedTimeRemaining
+{
+ return [_userInfo objectForKey:CPProgressEstimatedTimeRemainingKey];
+}
+
+- (void)setEstimatedTimeRemaining:(CPNumber)aTime
+{
+ [self setUserInfoObject:aTime forKey:CPProgressEstimatedTimeRemainingKey];
+}
+
+/*!
+ Returns the throughput, in units per second, or nil. It is stored in the user information
+ under CPProgressThroughputKey.
+*/
+- (CPNumber)throughput
+{
+ return [_userInfo objectForKey:CPProgressThroughputKey];
+}
+
+- (void)setThroughput:(CPNumber)aThroughput
+{
+ [self setUserInfoObject:aThroughput forKey:CPProgressThroughputKey];
+}
+
+/*!
+ Returns the kind of file operation, such as CPProgressFileOperationKindDownloading, or nil.
+ It is stored in the user information under CPProgressFileOperationKindKey.
+*/
+- (CPString)fileOperationKind
+{
+ return [_userInfo objectForKey:CPProgressFileOperationKindKey];
+}
+
+- (void)setFileOperationKind:(CPString)aKind
+{
+ [self setUserInfoObject:aKind forKey:CPProgressFileOperationKindKey];
+}
+
+/*
+ Sets an instance variable and announces the change of its key, when the value changes.
+*/
+- (void)_setValue:(id)aValue forIvar:(CPString)anIvarName key:(CPString)aKey
+{
+ if (self[anIvarName] === aValue)
+ return;
+
+ [self willChangeValueForKey:aKey];
+ self[anIvarName] = aValue;
+ [self didChangeValueForKey:aKey];
+}
+
+/*!
+ Returns a description of the progress object: its counts and fraction completed.
+*/
+- (CPString)description
+{
+ return [super description] + @" " + _completedUnitCount + @"/" + _totalUnitCount + @" (" + [self fractionCompleted] + @")" + (_cancelled ? @" cancelled" : @"") + (_paused ? @" paused" : @"");
+}
+
+@end
diff --git a/Foundation/CPString+Encoding.j b/Foundation/CPString+Encoding.j
index c462f244b..4e55c5987 100644
--- a/Foundation/CPString+Encoding.j
+++ b/Foundation/CPString+Encoding.j
@@ -117,7 +117,7 @@ var CPStringEncodingDecodeWithTextDecoder = function(aLabel, bytes, isLossy)
try
{
- return new TextDecoder(aLabel, { fatal: !isLossy }).decode(new Uint8Array(bytes));
+ return new TextDecoder(aLabel, { fatal: !isLossy }).decode(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes));
}
catch (anException)
{
@@ -142,7 +142,7 @@ var CPStringEncodingDecodeASCII = function(bytes, isLossy)
if (!isLossy)
return nil;
- bytes = bytes.map(function(aByte) { return aByte > 0x7F ? CPStringEncodingReplacementCharacter : aByte; });
+ bytes = Array.prototype.map.call(bytes, function(aByte) { return aByte > 0x7F ? CPStringEncodingReplacementCharacter : aByte; });
break;
}
@@ -705,6 +705,38 @@ var CPStringEncodingIsAvailable = function(aDescriptor)
}
};
+/*
+ Returns the bytes of data as an array of bytes. Data that holds a binary string, such as
+ file contents from CPItemProvider, is converted to a Uint8Array, which takes one byte for
+ each byte. Asking CPData for its bytes would build a plain array of JavaScript numbers, many
+ times the size, because CFData stores bytes that way, and keep it in the data.
+*/
+var CPStringEncodingBytesOfData = function(someData)
+{
+ if (!someData)
+ return [];
+
+ if (someData._bytes)
+ return someData._bytes;
+
+ var string = someData.rawString(),
+ count = string.length,
+ bytes = new Uint8Array(count);
+
+ for (var i = 0; i < count; i++)
+ bytes[i] = string.charCodeAt(i) & 0xFF;
+
+ return bytes;
+};
+
+/*
+ Returns the bytes from anIndex to the end, without copying when the bytes are a Uint8Array.
+*/
+var CPStringEncodingBytesFromIndex = function(bytes, anIndex)
+{
+ return bytes.subarray ? bytes.subarray(anIndex) : bytes.slice(anIndex);
+};
+
var CPStringEncodingDecode = function(bytes, anEncoding, isLossy)
{
var descriptor = CPStringEncodingDescriptors[anEncoding];
@@ -807,7 +839,12 @@ var CPStringEncodingDecode = function(bytes, anEncoding, isLossy)
*/
- (id)initWithData:(CPData)someData encoding:(CPStringEncoding)anEncoding
{
- return [self initWithBytes:someData ? [someData bytes] : [] length:someData ? [someData length] : 0 encoding:anEncoding];
+ var decoded = CPStringEncodingDecode(CPStringEncodingBytesOfData(someData), anEncoding, NO);
+
+ if (decoded === nil)
+ return nil;
+
+ return [self initWithString:decoded];
}
/*!
@@ -856,7 +893,7 @@ var CPStringEncodingDecode = function(bytes, anEncoding, isLossy)
*/
+ (CPStringEncoding)stringEncodingForData:(CPData)someData encodingOptions:(CPDictionary)someOptions convertedString:(CPStringRef)aStringRef usedLossyConversion:(BOOLRef)aLossyRef
{
- var bytes = someData ? [someData bytes] : [],
+ var bytes = CPStringEncodingBytesOfData(someData),
suggested = [someOptions objectForKey:CPStringEncodingDetectionSuggestedEncodingsKey] || [],
usesOnlySuggested = !![someOptions objectForKey:CPStringEncodingDetectionUseOnlySuggestedEncodingsKey],
allowLossy = [someOptions objectForKey:CPStringEncodingDetectionAllowLossyKey],
@@ -887,7 +924,7 @@ var CPStringEncodingDecode = function(bytes, anEncoding, isLossy)
if (bytes.length >= mark.length && mark.every(function(aByte, anIndex) { return bytes[anIndex] === aByte; }))
{
- var decoded = CPStringEncodingDecode(bytes.slice(mark.length), byteOrderMarks[i][1], NO);
+ var decoded = CPStringEncodingDecode(CPStringEncodingBytesFromIndex(bytes, mark.length), byteOrderMarks[i][1], NO);
if (decoded !== nil)
return result(byteOrderMarks[i][1], decoded, NO);
diff --git a/Foundation/Foundation.j b/Foundation/Foundation.j
index c2bcae4bd..729e6351a 100755
--- a/Foundation/Foundation.j
+++ b/Foundation/Foundation.j
@@ -65,6 +65,7 @@
@import "CPOperation.j"
@import "CPOperationQueue.j"
@import "CPPredicate.j"
+@import "CPProgress.j"
@import "CPPropertyListSerialization.j"
@import "CPRange.j"
@import "CPRunLoop.j"
diff --git a/Tests/Foundation/CPItemProviderTest.j b/Tests/Foundation/CPItemProviderTest.j
index 07611fdc6..cb71f1544 100644
--- a/Tests/Foundation/CPItemProviderTest.j
+++ b/Tests/Foundation/CPItemProviderTest.j
@@ -216,4 +216,95 @@
[self assert:@"a.png" equals:[providers[1] suggestedName]];
}
+/*
+ Installs a FileReader stand-in that reads nothing until the test drives it, and returns an
+ object through which the test reaches the reader.
+*/
+- (JSObject)installPendingFileReader
+{
+ var state = { reader: nil, aborted: NO };
+
+ globalThis.FileReader = function() { state.reader = this; this.readyState = 0; };
+ globalThis.FileReader.prototype.readAsArrayBuffer = function(aFile) { this.readyState = 1; };
+ globalThis.FileReader.prototype.abort = function()
+ {
+ state.aborted = YES;
+ this.readyState = 2;
+ this.onabort();
+ };
+
+ return state;
+}
+
+- (void)testFileProgressAndCancellation
+{
+ var state = [self installPendingFileReader],
+ provider = [CPItemProvider _itemProviderWithFile:{ name: "big.csv", type: "text/csv", size: 1000 }],
+ resultError = nil,
+ calls = 0,
+ progress = [provider loadDataRepresentationForTypeIdentifier:@"text/csv" completionHandler:function(someData, anError)
+ {
+ calls++;
+ resultError = anError;
+ }];
+
+ state.reader.onprogress({ lengthComputable: YES, loaded: 250 });
+
+ [self assert:0.25 equals:[progress fractionCompleted]];
+
+ [progress cancel];
+
+ [self assertTrue:state.aborted];
+ [self assert:1 equals:calls];
+ [self assert:CPCocoaErrorDomain equals:[resultError domain]];
+ [self assert:CPUserCancelledError equals:[resultError code]];
+}
+
+- (void)testFileProgressFinishes
+{
+ var state = [self installPendingFileReader],
+ provider = [CPItemProvider _itemProviderWithFile:{ name: "small.csv", type: "text/csv", size: 2 }],
+ result = nil,
+ progress = [provider loadDataRepresentationForTypeIdentifier:@"text/csv" completionHandler:function(someData, anError)
+ {
+ result = someData;
+ }];
+
+ state.reader.result = new Uint8Array([104, 105]).buffer;
+ state.reader.onload();
+
+ [self assertTrue:[progress isFinished]];
+ [self assert:@"hi" equals:[result rawString]];
+}
+
+- (void)testLoadObjectOfClassString
+{
+ var provider = [self providerWithType:@"text/csv" data:[CPData dataWithRawString:@"a,b"]],
+ result = nil;
+
+ [self assertTrue:[provider canLoadObjectOfClass:[CPString class]]];
+
+ [provider loadObjectOfClass:[CPString class] completionHandler:function(anObject, anError)
+ {
+ result = anObject;
+ }];
+
+ [self assert:@"a,b" equals:result];
+}
+
+- (void)testCannotLoadStringFromImage
+{
+ var provider = [self providerWithType:@"image/png" data:[CPData dataWithRawString:@"x"]],
+ resultError = nil;
+
+ [self assertFalse:[provider canLoadObjectOfClass:[CPString class]]];
+
+ [provider loadObjectOfClass:[CPString class] completionHandler:function(anObject, anError)
+ {
+ resultError = anError;
+ }];
+
+ [self assert:CPItemProviderItemUnavailableError equals:[resultError code]];
+}
+
@end
diff --git a/Tests/Foundation/CPProgressTest.j b/Tests/Foundation/CPProgressTest.j
new file mode 100644
index 000000000..8c1358a1a
--- /dev/null
+++ b/Tests/Foundation/CPProgressTest.j
@@ -0,0 +1,235 @@
+/*
+ * CPProgressTest.j
+ * Foundation
+ *
+ * Created by David Richardson on 2026-11-02.
+ * Copyright David Richardson 2026.
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+@import