#1575 Auto-select a group for new torrents according to criteria for each group

This commit is contained in:
Mitchell Livingston 2008-12-08 03:26:28 +00:00
parent e3b039cd21
commit 678a3f7c3e
10 changed files with 1649 additions and 1153 deletions

3
NEWS
View File

@ -7,7 +7,8 @@ NEWS file for Transmission <http://www.transmissionbt.com/>
+ Support BitTorrent Enhancement Proposal #6 "Fast Extension"
+ Support BitTorrent Enhancement Proposal #21 "Extension for Partial Seeds"
- Mac
+ Groups (moved to preferences) can have a default location when adding transfers
+ Groups (moved to preferences) can be auto-assigned to transfers when adding based on name and tracker (10.5-only)
+ Groups can have a default location when adding transfers
+ Bonjour support for the web interface
+ File filter field in the inspector
- GTK+

View File

@ -108,6 +108,7 @@
A234D0D20C79FB3600A82373 /* NSMenuAdditions.m in Sources */ = {isa = PBXBuildFile; fileRef = A234D0D00C79FB3600A82373 /* NSMenuAdditions.m */; };
A2385DD40BFE06C800B24EF6 /* DragOverlayWindow.m in Sources */ = {isa = PBXBuildFile; fileRef = A2385DD20BFE06C800B24EF6 /* DragOverlayWindow.m */; };
A2399CCD0CD3852300225B2B /* NSApplicationAdditions.m in Sources */ = {isa = PBXBuildFile; fileRef = A2399CCC0CD3852300225B2B /* NSApplicationAdditions.m */; };
A23A0E310EECB0740091C885 /* GroupRules.xib in Resources */ = {isa = PBXBuildFile; fileRef = A23A0E300EECB0740091C885 /* GroupRules.xib */; };
A23F4FF20D1D98AD002FCB97 /* PrefsWindow.xib in Resources */ = {isa = PBXBuildFile; fileRef = A23F4FF00D1D98AD002FCB97 /* PrefsWindow.xib */; };
A23F50020D1D99D7002FCB97 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = A23F50000D1D99D7002FCB97 /* MainMenu.xib */; };
A241528B0C0261B8007DD3B4 /* Globe.png in Resources */ = {isa = PBXBuildFile; fileRef = A2FB06950BFF484A0095564D /* Globe.png */; };
@ -513,6 +514,7 @@
A2385DD30BFE06C800B24EF6 /* DragOverlayWindow.h */ = {isa = PBXFileReference; fileEncoding = 30; lastKnownFileType = sourcecode.c.h; name = DragOverlayWindow.h; path = macosx/DragOverlayWindow.h; sourceTree = "<group>"; };
A2399CCB0CD3852300225B2B /* NSApplicationAdditions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = NSApplicationAdditions.h; path = macosx/NSApplicationAdditions.h; sourceTree = "<group>"; };
A2399CCC0CD3852300225B2B /* NSApplicationAdditions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = NSApplicationAdditions.m; path = macosx/NSApplicationAdditions.m; sourceTree = "<group>"; };
A23A0E300EECB0740091C885 /* GroupRules.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = GroupRules.xib; path = macosx/GroupRules.xib; sourceTree = "<group>"; };
A245030B0D6A1FB000B49D00 /* UpArrowGroupTemplate.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = UpArrowGroupTemplate.png; path = macosx/Images/UpArrowGroupTemplate.png; sourceTree = "<group>"; };
A245030D0D6A1FBC00B49D00 /* DownArrowGroupTemplate.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = DownArrowGroupTemplate.png; path = macosx/Images/DownArrowGroupTemplate.png; sourceTree = "<group>"; };
A24621350C769CF400088E81 /* trevent.h */ = {isa = PBXFileReference; fileEncoding = 30; lastKnownFileType = sourcecode.c.h; name = trevent.h; path = libtransmission/trevent.h; sourceTree = "<group>"; };
@ -1005,6 +1007,7 @@
A29576110D11D8DD0093B167 /* InfoWindow.xib */,
A29576010D11D63C0093B167 /* Creator.xib */,
A26AF27C0D2DBDDF00FF7140 /* AddWindow.xib */,
A23A0E300EECB0740091C885 /* GroupRules.xib */,
A233BD320D8C6585007EE7B4 /* MessageWindow.xib */,
A233BD680D8CF2C7007EE7B4 /* StatsWindow.xib */,
A231274B0D11D0B7003F9AFF /* AboutWindow.xib */,
@ -1769,6 +1772,7 @@
A250EEB60E2ED87B00A688E6 /* web in Resources */,
A22F1E550E7DA8030065DB9D /* sparkle_dsa_pub.pem in Resources */,
A2E2EA920EE321C200EB6308 /* Groups.png in Resources */,
A23A0E310EECB0740091C885 /* GroupRules.xib in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@ -37,6 +37,8 @@
- (void) confirmAdd;
- (void) setDestinationPath: (NSString *) destination;
- (void) folderChoiceClosed: (NSOpenPanel *) openPanel returnCode: (NSInteger) code contextInfo: (void *) contextInfo;
- (void) setGroupsMenu;
@ -64,7 +66,13 @@
&& [[NSUserDefaults standardUserDefaults] boolForKey: @"DeleteOriginalTorrent"]);
fDeleteEnable = deleteTorrent == TORRENT_FILE_DEFAULT;
fGroupValue = -1;
fGroupValue = [torrent groupValue];
#warning factor in if there already is a destination
// set the groups download location if there is one
if ([[GroupsController groups] usesCustomDownloadLocationForIndex: fGroupValue] &&
[[GroupsController groups] customDownloadLocationForIndex: fGroupValue])
[self setDestinationPath: [[GroupsController groups] customDownloadLocationForIndex: fGroupValue]];
}
return self;
}
@ -91,7 +99,7 @@
[self updateStatusField: nil];
[self setGroupsMenu];
[fGroupPopUp selectItemWithTag: -1];
[fGroupPopUp selectItemWithTag: fGroupValue];
[fStartCheck setState: [[NSUserDefaults standardUserDefaults] boolForKey: @"AutoStartDownload"] ? NSOnState : NSOffState];
@ -99,14 +107,7 @@
[fDeleteCheck setEnabled: fDeleteEnable];
if (fDestination)
{
[fLocationField setStringValue: [fDestination stringByAbbreviatingWithTildeInPath]];
[fLocationField setToolTip: fDestination];
ExpandedPathToIconTransformer * iconTransformer = [[ExpandedPathToIconTransformer alloc] init];
[fLocationImageView setImage: [iconTransformer transformedValue: fDestination]];
[iconTransformer release];
}
[self setDestinationPath: fDestination];
else
{
[fLocationField setStringValue: @""];
@ -247,7 +248,7 @@
fTimer = nil;
[fTorrent setWaitToStart: [fStartCheck state] == NSOnState];
[fTorrent setGroupValue: [[fGroupPopUp selectedItem] tag]];
[fTorrent setGroupValue: fGroupValue];
if ([fDeleteCheck state] == NSOnState)
[fTorrent trashTorrent];
@ -260,8 +261,14 @@
- (void) setDestinationPath: (NSString *) destination
{
[fDestination release];
fDestination = [destination retain];
destination = [destination stringByExpandingTildeInPath];
if (!fDestination || ![fDestination isEqualToString: destination])
{
[fDestination release];
fDestination = [destination retain];
[fTorrent changeDownloadFolder: fDestination];
}
[fLocationField setStringValue: [fDestination stringByAbbreviatingWithTildeInPath]];
[fLocationField setToolTip: fDestination];
@ -269,8 +276,6 @@
ExpandedPathToIconTransformer * iconTransformer = [[ExpandedPathToIconTransformer alloc] init];
[fLocationImageView setImage: [iconTransformer transformedValue: fDestination]];
[iconTransformer release];
[fTorrent changeDownloadFolder: fDestination];
}
- (void) folderChoiceClosed: (NSOpenPanel *) openPanel returnCode: (NSInteger) code contextInfo: (void *) contextInfo

View File

@ -860,6 +860,11 @@ static void sleepCallback(void * controller, io_service_t y, natural_t messageTy
{
[torrent setWaitToStart: [fDefaults boolForKey: @"AutoStartDownload"]];
#warning move into torrent init?
if ([torrent groupValue] != -1 && [[GroupsController groups] usesCustomDownloadLocationForIndex: [torrent groupValue]]
&& [[GroupsController groups] customDownloadLocationForIndex: [torrent groupValue]])
[torrent changeDownloadFolder: [[GroupsController groups] customDownloadLocationForIndex: [torrent groupValue]]];
[torrent update];
[fTorrents addObject: torrent];
[torrent release];

View File

@ -23,6 +23,7 @@
*****************************************************************************/
#import <Cocoa/Cocoa.h>
#import "Torrent.h"
@interface GroupsController : NSObject
{
@ -50,6 +51,12 @@
- (NSString *) customDownloadLocationForIndex: (NSInteger) index;
- (void) setCustomDownloadLocation: (NSString *) location forIndex: (NSInteger) index;
- (BOOL) usesAutoAssignRulesForIndex: (NSInteger) index;
- (void) setUsesAutoAssignRules: (BOOL) useAutoAssignRules forIndex: (NSInteger) index;
- (NSArray *) autoAssignRulesForIndex: (NSInteger) index;
- (void) setAutoAssignRules: (NSArray *) rules forIndex: (NSInteger) index;
- (void) addNewGroup;
- (void) removeGroupWithRowIndex: (NSInteger) row;
@ -57,4 +64,5 @@
- (NSMenu *) groupMenuWithTarget: (id) target action: (SEL) action isSmall: (BOOL) small;
- (NSInteger) groupIndexForTorrent: (Torrent *) torrent;
@end

View File

@ -35,6 +35,8 @@
- (NSImage *) imageForGroup: (NSMutableDictionary *) dict;
- (BOOL) torrent: (Torrent *) torrent doesMatchRulesForGroupAtIndex: (NSInteger) index;
@end
@implementation GroupsController
@ -176,10 +178,9 @@ GroupsController * fGroupsInstance = nil;
{
NSMutableDictionary * dict = [fGroups objectAtIndex: [self rowValueForIndex: index]];
[dict setObject: [NSNumber numberWithBool:useCustomLocation] forKey: @"UsesCustomDownloadLocation"];
[dict setObject: [NSNumber numberWithBool: useCustomLocation] forKey: @"UsesCustomDownloadLocation"];
[[GroupsController groups] saveGroups];
[[NSNotificationCenter defaultCenter] postNotificationName: @"UpdateGroups" object: self];
}
- (NSString *) customDownloadLocationForIndex: (NSInteger) index
@ -195,10 +196,54 @@ GroupsController * fGroupsInstance = nil;
if (location)
[dict setObject: location forKey: @"CustomDownloadLocation"];
else
{
[dict removeObjectForKey: @"CustomDownloadLocation"];
[self setUsesCustomDownloadLocation: NO forIndex: index];
}
[[GroupsController groups] saveGroups];
[[NSNotificationCenter defaultCenter] postNotificationName: @"UpdateGroups" object: self];
}
- (BOOL) usesAutoAssignRulesForIndex: (NSInteger) index
{
NSInteger orderIndex = [self rowValueForIndex: index];
if (orderIndex == -1)
return NO;
NSNumber * assignRules = [[fGroups objectAtIndex: orderIndex] objectForKey: @"UsesAutoAssignRules"];
return assignRules && [assignRules boolValue];
}
- (void) setUsesAutoAssignRules: (BOOL) useAutoAssignRules forIndex: (NSInteger) index
{
NSMutableDictionary * dict = [fGroups objectAtIndex: [self rowValueForIndex: index]];
[dict setObject: [NSNumber numberWithBool: useAutoAssignRules] forKey: @"UsesAutoAssignRules"];
[[GroupsController groups] saveGroups];
}
- (NSArray *) autoAssignRulesForIndex: (NSInteger) index
{
NSInteger orderIndex = [self rowValueForIndex: index];
return orderIndex != -1 ? [[fGroups objectAtIndex: orderIndex] objectForKey: @"AutoAssignRules"] : nil;
}
- (void) setAutoAssignRules: (NSArray *) rules forIndex: (NSInteger) index
{
NSMutableDictionary * dict = [fGroups objectAtIndex: [self rowValueForIndex: index]];
if (rules && [rules count] > 0)
{
[dict setObject: rules forKey: @"AutoAssignRules"];
[[GroupsController groups] saveGroups];
}
else
{
[dict removeObjectForKey: @"AutoAssignRules"];
[self setUsesAutoAssignRules: NO forIndex: index];
}
}
- (void) addNewGroup
@ -315,6 +360,19 @@ GroupsController * fGroupsInstance = nil;
return [menu autorelease];
}
- (NSInteger) groupIndexForTorrent: (Torrent *) torrent;
{
NSEnumerator * enumerator = [fGroups objectEnumerator];
NSMutableDictionary * group;
while ((group = [enumerator nextObject]))
{
NSInteger row = [[group objectForKey: @"Index"] intValue];
if ([self torrent: torrent doesMatchRulesForGroupAtIndex: row])
return row;
}
return -1; // Default to no group
}
@end
@implementation GroupsController (Private)
@ -370,4 +428,56 @@ GroupsController * fGroupsInstance = nil;
return icon;
}
- (BOOL) torrent: (Torrent *) torrent doesMatchRulesForGroupAtIndex: (NSInteger) index
{
if (![self usesAutoAssignRulesForIndex: index])
return NO;
NSArray * rules = [self autoAssignRulesForIndex: index];
if (!rules || [rules count] == 0)
return NO;
#warning should rules be dict instead of array?
NSEnumerator * iterator = [rules objectEnumerator];
NSArray * rule = nil;
while ((rule = [iterator nextObject]))
{
NSString * type = [rule objectAtIndex: 0], * place = [rule objectAtIndex: 1], * match = [rule objectAtIndex: 2],
* value = nil;
if ([type isEqualToString: @"title"])
value = [torrent name];
else if ([type isEqualToString: @"tracker"])
{
#warning consider all trackers
value = [torrent trackerAddressAnnounce];
}
else
continue;
NSStringCompareOptions options = NSCaseInsensitiveSearch;
if ([place isEqualToString: @"ends"])
options += NSBackwardsSearch;
NSRange result = [value rangeOfString: match options: options];
if ([place isEqualToString: @"begins"])
{
if (result.location != 0)
return NO;
}
else if ([place isEqualToString: @"contains"])
{
if (result.location == NSNotFound)
return NO;
}
else if ([place isEqualToString: @"ends"])
{
if (NSMaxRange(result) == [value length])
return NO;
}
else
continue;
}
return YES;
}
@end

View File

@ -35,10 +35,21 @@
IBOutlet NSTextField * fSelectedColorNameField;
IBOutlet NSButton * fCustomLocationEnableCheck;
IBOutlet NSPopUpButton * fCustomLocationPopUp;
IBOutlet NSView * fGroupRulesPrefsContainer;
IBOutlet NSButton * fAutoAssignRulesEnableCheck;
IBOutlet NSButton * fAutoAssignRulesEditButton;
IBOutlet NSWindow * fGroupRulesSheetWindow;
IBOutlet NSRuleEditor * fRuleEditor;
}
- (void) addRemoveGroup: (id) sender;
- (IBAction) toggleUseCustomDownloadLocation: (id) sender;
- (IBAction) customDownloadLocationSheetShow: (id) sender;
- (IBAction) toggleUseAutoAssignRules: (id) sender;
- (IBAction) orderFrontRulesSheet: (id) sender;
- (IBAction) cancelRules: (id) sender;
- (IBAction) saveRules: (id) sender;
@end

View File

@ -50,6 +50,7 @@
[fAddRemoveControl sizeToFit];
[fAddRemoveControl setLabel: @"+" forSegment: ADD_TAG];
[fAddRemoveControl setLabel: @"-" forSegment: REMOVE_TAG];
[fGroupRulesPrefsContainer setHidden: YES]; //get rid of container when 10.5-only
}
[fSelectedColorView addObserver: self forKeyPath: @"color" options: 0 context: NULL];
@ -235,6 +236,155 @@
[fCustomLocationPopUp selectItemAtIndex: 0];
}
#pragma mark -
#pragma mark Rule editor
- (IBAction) toggleUseAutoAssignRules: (id) sender;
{
NSInteger index = [[GroupsController groups] indexForRow: [fTableView selectedRow]];
if ([fAutoAssignRulesEnableCheck state] == NSOnState)
{
if ([[GroupsController groups] autoAssignRulesForIndex: index])
[[GroupsController groups] setUsesAutoAssignRules: YES forIndex: index];
else
[self orderFrontRulesSheet: nil];
}
else
[[GroupsController groups] setUsesAutoAssignRules: NO forIndex: index];
[fAutoAssignRulesEditButton setEnabled: [fAutoAssignRulesEnableCheck state] == NSOnState];
}
- (IBAction) orderFrontRulesSheet: (id) sender;
{
if (!fGroupRulesSheetWindow)
[NSBundle loadNibNamed: @"GroupRules" owner: self];
[fRuleEditor removeRowsAtIndexes: [NSIndexSet indexSetWithIndexesInRange: NSMakeRange(0, [fRuleEditor numberOfRows])]
includeSubrows: YES];
NSInteger index = [[GroupsController groups] indexForRow: [fTableView selectedRow]];
NSArray * rules = [[GroupsController groups] autoAssignRulesForIndex: index];
if (rules)
{
for (NSInteger index = 0; index < [rules count]; index++)
{
[fRuleEditor addRow: nil];
[fRuleEditor setCriteria: [rules objectAtIndex: index] andDisplayValues: [NSArray array] forRowAtIndex: index];
}
}
if ([fRuleEditor numberOfRows] == 0)
[fRuleEditor addRow: nil];
[NSApp beginSheet: fGroupRulesSheetWindow
modalForWindow: [fTableView window]
modalDelegate: nil
didEndSelector: NULL
contextInfo: NULL];
}
- (IBAction) cancelRules: (id) sender;
{
[fGroupRulesSheetWindow orderOut: nil];
[NSApp endSheet: fGroupRulesSheetWindow];
NSInteger index = [[GroupsController groups] indexForRow: [fTableView selectedRow]];
if (![[GroupsController groups] autoAssignRulesForIndex: index])
{
[[GroupsController groups] setUsesAutoAssignRules: NO forIndex: index];
[fAutoAssignRulesEnableCheck setState: NO];
[fAutoAssignRulesEditButton setEnabled: NO];
}
}
- (IBAction) saveRules: (id) sender;
{
[fGroupRulesSheetWindow orderOut: nil];
[NSApp endSheet: fGroupRulesSheetWindow];
NSInteger index = [[GroupsController groups] indexForRow: [fTableView selectedRow]];
[[GroupsController groups] setUsesAutoAssignRules: YES forIndex: index];
NSMutableArray * rules = [NSMutableArray arrayWithCapacity: [fRuleEditor numberOfRows]];
for (NSInteger index = 0; index < [fRuleEditor numberOfRows]; ++index)
{
NSString * string = [[[fRuleEditor displayValuesForRow: index] objectAtIndex: 2] stringValue];
if (string && [string length] > 0)
{
NSMutableArray * rule = [[[fRuleEditor criteriaForRow: index] mutableCopy] autorelease];
[rule replaceObjectAtIndex: 2 withObject: string];
[rules addObject: rule];
}
}
[[GroupsController groups] setAutoAssignRules: rules forIndex: index];
[fAutoAssignRulesEnableCheck setState: [[GroupsController groups] usesAutoAssignRulesForIndex: index]];
[fAutoAssignRulesEditButton setEnabled: [fAutoAssignRulesEnableCheck state] == NSOnState];
}
static NSString * torrentTitleCriteria = @"title";
static NSString * trackerURLCriteria = @"tracker";
static NSString * startsWithCriteria = @"begins";
static NSString * containsCriteria = @"contains";
static NSString * endsWithCriteria = @"ends";
- (NSInteger) ruleEditor: (NSRuleEditor *) editor numberOfChildrenForCriterion: (id) criterion withRowType: (NSRuleEditorRowType) rowType
{
if (!criterion)
return 2;
else if ([criterion isEqualToString: torrentTitleCriteria] || [criterion isEqualToString: trackerURLCriteria])
return 3;
else if ([criterion isEqualToString: startsWithCriteria] || [criterion isEqualToString: containsCriteria]
|| [criterion isEqualToString: endsWithCriteria])
return 1;
else
return 0;
}
- (id) ruleEditor: (NSRuleEditor *) editor child: (NSInteger) index forCriterion: (id) criterion
withRowType: (NSRuleEditorRowType) rowType
{
if (criterion == nil)
return [[NSArray arrayWithObjects: torrentTitleCriteria, trackerURLCriteria, nil] objectAtIndex: index];
else if ([criterion isEqualToString: torrentTitleCriteria] || [criterion isEqualToString: trackerURLCriteria])
return [[NSArray arrayWithObjects: startsWithCriteria, containsCriteria, endsWithCriteria, nil] objectAtIndex: index];
else
return @"";
}
- (id) ruleEditor: (NSRuleEditor *) editor displayValueForCriterion: (id) criterion inRow: (NSInteger) row
{
if ([criterion isEqualToString: torrentTitleCriteria])
return NSLocalizedString(@"Torrent Title", "Groups -> rule editor");
else if ([criterion isEqualToString: trackerURLCriteria])
return NSLocalizedString(@"Tracker URL", "Groups -> rule editor");
else if ([criterion isEqualToString: startsWithCriteria])
return NSLocalizedString(@"Starts With", "Groups -> rule editor");
else if ([criterion isEqualToString: containsCriteria])
return NSLocalizedString(@"Contains", "Groups -> rule editor");
else if ([criterion isEqualToString: endsWithCriteria])
return NSLocalizedString(@"Ends With", "Groups -> rule editor");
else
{
NSTextField * field = [[NSTextField alloc] initWithFrame: NSMakeRect(0, 0, 130, 22)];
[field setStringValue: criterion];
return [field autorelease];
}
}
- (void) ruleEditorRowsDidChange: (NSNotification *) notification
{
CGFloat rowHeight = [fRuleEditor rowHeight];
NSInteger numberOfRows = [fRuleEditor numberOfRows];
CGFloat ruleEditorHeight = numberOfRows * rowHeight;
CGFloat heightDifference = ruleEditorHeight - [fRuleEditor frame].size.height;
NSRect windowFrame = [fRuleEditor window].frame;
windowFrame.size.height += heightDifference;
windowFrame.origin.y -= heightDifference;
[fRuleEditor.window setFrame: windowFrame display: YES animate: YES];
}
@end
@implementation GroupsPrefsController (Private)
@ -265,6 +415,10 @@
[[fCustomLocationPopUp itemAtIndex: 0] setTitle: @""];
[[fCustomLocationPopUp itemAtIndex: 0] setImage: nil];
}
[fAutoAssignRulesEnableCheck setState: [[GroupsController groups] usesAutoAssignRulesForIndex: index]];
[fAutoAssignRulesEnableCheck setEnabled: YES];
[fAutoAssignRulesEditButton setEnabled: ([fAutoAssignRulesEnableCheck state] == NSOnState)];
}
else
{
@ -274,6 +428,8 @@
[fSelectedColorNameField setEnabled: NO];
[fCustomLocationEnableCheck setEnabled: NO];
[fCustomLocationPopUp setEnabled: NO];
[fAutoAssignRulesEnableCheck setEnabled: NO];
[fAutoAssignRulesEditButton setEnabled: NO];
}
}

View File

@ -221,6 +221,9 @@ void completenessChangeCallback(tr_torrent * torrent, tr_completeness status, vo
- (void) changeDownloadFolder: (NSString *) folder
{
if (fDownloadFolder && [folder isEqualToString: fDownloadFolder])
return;
[fDownloadFolder release];
fDownloadFolder = [folder retain];
@ -1761,7 +1764,7 @@ void completenessChangeCallback(tr_torrent * torrent, tr_completeness status, vo
fResumeOnWake = NO;
fOrderValue = orderValue ? [orderValue intValue] : tr_sessionCountTorrents(lib) - 1;
fGroupValue = groupValue ? [groupValue intValue] : -1;
fGroupValue = groupValue ? [groupValue intValue] : [[GroupsController groups] groupIndexForTorrent: self];
fAddedTrackers = addedTrackers ? [addedTrackers boolValue] : NO;

File diff suppressed because it is too large Load Diff