diff --git a/sites/all/modules/feeds/CHANGELOG.txt b/sites/all/modules/feeds/CHANGELOG.txt new file mode 100644 index 0000000000000000000000000000000000000000..fcbdda84dc161037fd34cfbe4b80af94ea2ee81e --- /dev/null +++ b/sites/all/modules/feeds/CHANGELOG.txt @@ -0,0 +1,541 @@ +Feeds 7.x 2.0 Alpha 5, 2012-05-28 +--------------------------------- + +- Issue #1515204 by gnucifer: Malformed destination uri in FeedsEnclosure. +- Issue #1450714 by getgood: ATOM parser ignores 'updated' tag +- Issue #1152940 by bblake, rickmanelius, g089h515r806, darrylri, iMiksu, + sdrycroft, johnbarclay, batje, axel.rutz, GaëlG: Feeds term import with + hierarchy and weight +- Issue #1406260 by Xen, logaritmisk: Fetchers without source configuration + fails. +- Issue #1407670 by Ivan Simonov, GaëlG, franz: drupal_strlen() doesn't work + when parsing multibyte strings in CSVParser. +- Issue #1213472 by paulgemini, Nigel_S, emarchak, Jorenm: Fixed Unsupported + operand types in FeedsConfigurable. +- Follow-up to #1245094 by agoradesign: menu links not really fixed. +- Issue #1245094 by chrisdejager, dman: Fixed Node menu link deleted on update. +- Issue #712304 by derhasi, twistor, jerdavis, alex_b, rjbrown99, rbayliss | + ManyNancy: Fixed Batch import does not continue where it left off, instead + starts from the beginning. +- Issue #959984 by kidrobot, desmondmorris, slashrsm, flailingmaster, rfay | + dasjo: Fixed taxonomy_node_get_terms() doesn't work with drupal 7. +- Issue #1140194 by orb | emorency: Fixed SQLSTATE[HY000]: General error: 1366 + Incorrect string value for a field with accents. +- Issue #1427642 by elliotttf: Fixed Use drupal_exit() rather than exit() in + PuSH/FeedsHTTPFetcher callbacks. +- Issue #1418382: Fixed unsubscribe requests to PuSH hubs failing due to + transaction in node_delete(). +- Issue #870556: Fixed PuSH verifications run before the subscription records + have been saved." +- Issue #1289598 by emackn, andypost: Fixed Remove check_plain() on form + options. +- Issue #996808 by twistor, marcvangend, joshuajabbour: Fixed Update existing + doesn't reset targets that have real_target() set. +- Issue #1219180 by dandaman: Fixed Minor error in API code samples. +- Issue #686470 by johnv, wojtha, febbraro | rjbrown99: Fixed Filefield mapper: + URLs with spaces not parsed properly. +- Issue #1380636 by gnucifer, emackn: Fixed Wrong response headers reported on + 3xx requests in function http_request_get(). +- Issue #1382208 by slcp: Fixed FeedsSource.inc sourceSave() and sourceDelete() + function descriptions are the wrong way round. +- Issue #1372074 by twistor, emackn: Fixed feeds_http_request() does not cache + when using drupal_http_request(). +- Issue #1191330 by twistor: Added Allow feeds_mapper().test to set field + instance settings. +- Issue #1225672 by StepanKuzmin, tekante | guillaumev: Fixed Bug when importing + a single year. +- Issue #1035684 by mikejoconnor: Source / Target sort order +- Issue #1005128 by dasjo, fago: Rules integration & enable modules to customize + imports. +- Issue #1228568 by pcambra: Add user status to FeedsUserProcessor +- Issue #1139676 by Dave Reid, cosmicdreams: Fixed feeds_alter() doesn't support + modulename.feeds.inc files supported by feeds_hook_info(). +- Issue #1347894 by juampy | colin_young: Fixed Clear cache causes integrity + constraint violation. Fix for feeds info file +- Issue #1298326 by twistor, Dave Reid, and emackn: Fixed Only execute + rebuild_menu() when necessary. +- Issue #1228570 by pcambra: Added user language to FeedsUserProcessor. +- Issue #1206042 by anon: Let node titles have unique option. +- Issue #126689 by tcindie :Additional targets for nodes +- issue #126689 by tcindie : 1298326Issue PageOnly execute rebuild_menu when + necessary +- Issue #1128418 by mongolito404, Niklas Fiekas, jief: Fixed Deprecated: + Assigning the return value of new by reference is deprecated in + feeds_include_library(). +- Issue #1046916 by eosrei: importing nodes with their original NID +- Issue #1197646: Skip importer config form validation if a machine name was not + provided. +- Issue #1248712: Show an empty row result and hide the Save button if no + importers are available. +- Issue #1248710: Use the core-provided machine_name FAPI element for the + importer machine name field. +- Issue #1235394: Fixed menu paths violate UX standards and could not use + breadcrumbs. +- Issue #1248648 by twistor, Dave Reid: Fixed bugs and inconsistencies in + FeedsRSStoNodesTest. + +Feeds 7.x 2.0 Alpha 4, 2011-06-28 +--------------------------------- + +- Issue #1161810: Fixed declaration of FeedsSource::instance() should be + compatible with that of FeedsConfigurable::instance(). +- Issue #1201638: Plugins should be listed in feeds.info as files[] records for + the class registry. +- Issue #1149226: Fixed invalid message parameter passed into feeds_log from + FeedsProcessor::process(). +- Issue #1044882 by rfay, Dave Reid: Fixed indexes for {feeds_item} are too long + and can cause problems during install or uninstall. +- Fixing PHP strict error in _parser_common_syndication_atom10_parse(). +- Added support for feeds hooks to be located in modulename.feeds.inc. +- Issue #1191554: Fixed failures in FeedsUIUserInterfaceTestCase. +- Issue #1191564: Use FeedsWebTestCase for FeedsDateTimeTest. +- Issue #1191494 by twistor, Dave Reid: Fixed link to node type feed importer + did not use node_access(). +- Issue #1191450: Fixed mismatch of arguments for t(). +- Fixed possible XSS with field labels in Feed importer mapping settings. +- Fixed coder violations and standards. +- Issue #723548: Added unit tests for feeds_valid_url(). +- Fixed PHP notice with undefined variables in + http_request_get_common_syndication(). +- Issue #723548: Added support for feed URLs with feed:// and webcal://. +- Fixed error when calling form_set_error() and title field on follow-up to fix + feed node title fields not actually un-required. +- Issue #1191210: Added feeds_field_extra_fields() so the 'Feed' fieldset can be + re-ordered through the Field UI. +- Issue #1191194: Fixed test failure in FeedsCSVtoUsersTest due to lack of + 'administer users' permission. +- Issue #1191200: Fixed display of the description field on the feed item + content type. +- Issue #1066286: Added test to ensure 'Feed items' doesn't display on non-feed + nodes. +- Use hook_form_node_form_alter() rather than hook_form_alter(). +- Simplify FeedsMapperTestCase::createContentType() using + DrupalWebTestCase::drupalCreateContentType(). +- Issue #983220 by twistor: Fixed field mapper tests failed due to number.module + not being enabled. +- Issue #1085092 by pfrenssen: Fixed module_list() is called twice in + feeds_alter(). +- Fixes and cleanups to tests. +- Use fetchObject() rather than fetch() since it is more explicit. +- Issue #1008384 by twistor: Fixed feeds not pulling publication date with feed + using dc:date and RSS 2.0. +- Issue #769084: Fixed use of isset() rather than !empty() causes import + problems with _parser_common_syndication_RSS20_parse(). +- Issue #914210 by jyee, Dave Reid: Added mapper for user raw password. +- Issue #974494: Fixed PHP notice 'Undefined property: stdClass::$openid in + FeedsUserProcessor->entitySave()'. +- Issue #1134684 by rfay, Dave Reid: Fixed improper parameters for + file_field_widget_uri(). +- Issue #1055582: Fixed strict notice that FeedsDateTime::setTimezone() is not + compatible with DateTime::setTimezone(). +- Issue #1085194: Not all selected mappings are removed. +- Issue #1032640: Added basic token integration. +- Issue #1066806: Use hook_entity_insert/update/delete rather than separate + node, taxonomy term, and user hooks. +- Issue #1066822: Fixed bugs and inconsistencies with test files and getInfo() + declarations. +- Issue #1066810: Fixed list of 'Expire nodes' options in FeedsNodeProcessor. +- Issue #1066286: Fixed 'View items' tab added to all content types. +- Issue #1011958 by David Goode: Allow hashes to be updated when content in a + feed is updated. +- Issue #1048642 by greg.harvey: Check for remove_flags in Feeds UI before using + that variable. +- #967018 jcarlson34, David Goode, alex_b: Mapping to String lists not supported + +Feeds 7.x 2.0 Alpha 3, 2011-01-14 +--------------------------------- + +- Add index for looking up by entity_type + url/ guid to feeds_item table. +- #994026 tristanoneil: Optionally defuse email addresses. + +Feeds 7.x 2.0 Alpha 2, 2010-11-02 +--------------------------------- + +- #940866 tristanoneil: PHP 5.3 FeedsImporter::copy function must be compatible. +- #944986 tristanoneil: Link Mapper Upgrade. +- #959066 tristanoneil: Remove old mappers and tests. +- #883342 Steven Jones: Don't force usage of cURL. +- #776854 imclean et. al.: Support parsing CSV files without column headers. +- Ian Ward: ensure that arrays of numerics are handled correctly. +- #953728 tristanoneil: Upgrade text formats, use on all processors. +- alex_b: Fix file mapper, add file mapper tests, generate flickr.xml and + files.csv dynamically. +- #953538 yhahn: Remove BOM from UTF-8 files. + Adds sanitizeFile() and sanitizeRaw() methods to FeedsFetcherResult. + Extending classes that override either the getRaw() or getFilePath() methods + should call these sanitization methods to ensure that the returned output / + file has been cleaned for parsing. +- #606612 alex_b: More detailed log. +- #949236 Ian Ward, alex_b: Allow mapping empty values to fields. +- #912630 twistor, alex_b: FeedsParserResult: make items accessible for + modification. +- #933306 alex_b: Fix Feeds creates subscriptions for not existing importers. +- #946822 twistor: FeedsSitemapParser broken: Serialization of + 'SimpleXMLElement' is not allowed. +- #949916 alex_b: Convert values mapped to user->created. +- #949236 Ian Ward: Allow mapping empty values to fields. +- Allow for mapping to list_number field types. +- Fix entity inspection in file fetcher. +- #932772 alex_b: FeedsProcessor: Consolidate process() and clear(). + FeedsProcessor now implements the process() and clear() methods for creating + and deleting entities. The extending processors FeedsNodeProcessor, + FeedsTermProcessor and FeedsUserProcessor merely implement entity manipulation + methods (newEntity(), entityLoad(), entitySave(), entityDeleteMultiple()...). + This brings features previously only available on FeedsNodeProcessor to all + entity processors: fast change detection on imported items with hashes, + batching on process() and clear() and in the case of FeedsUserProcessor, the + actual implementation of clear(). Together with #929066 this is a further + step towards harmonizing features of processor plugins. +- Move term and user validation into a validate() method. +- Remove check for present name and mail. Needs to be solved on a more pluggable + level. +- FeedsTermProcessor: Do not filter taxonomy_term_data table by vid when + clearing. +- #932572 alex_b: FeedsTermProcessor: Batch term processing. +- Remove check for present name in terms that are imported. If we do such + validation, we need to do this on a more pluggable level. +- Fix Feeds News tests, add a 'description' field to the Feeds Item content + type. +- #728534 alex_b: Remove FeedsFeedNodeProcessor. If you have used + FeedsFeedNodeProcessor in the past, use FeedsNodeProcessor (Node Processor) + instead now. It supports all of FeedsFeedNodeProcessor's functionality and + more. +- #929066 alex_b: Track all imported items. Note: All views that use 'Feeds + Item' fields or relationships need updating. +- #930018 alex_b: Don't show file upload when 'Supply path directly' is + selected. +- #927892 alex_b: Add "Process in background" feature. Allows one-off imports to + be processed in the background rather than using Batch API. Useful for very + large imports. +- #929058 alex_b: Report status of source on import and delete forms, track + last updated time on a source level. +- #928836: Set progress floating point directly. Note: fetchers and parsers + must use $state->progress() for setting the batch progress now IF they support + batching. +- #928728: Track source states by stage, not by plugin. Note: call signature of + FeedsSource::state() has changed. +- Remove 6.x upgrade hooks. +- #923318: Fix Fatal error: Call to a member function import() on a non-object + occuring on cron. +- Clean up basic settings form. +- Make getConfig() include configuration defaults. + +Feeds 7.x 2.0 Alpha 1, 2010-09-29 +--------------------------------- + +- #925842 alex_b: Support batching through directories on disk. +- #625196 mstrelan, alex_b: Fix array_merge(), array_intersect_key() warnings. +- Remove hidden setting feeds_worker_time. Use hook_cron_queue_info_alter() to + modify this setting. +- #744660-80 alex_b: Expand batch support to fetchers and parsers. + - Removed FeedsBatch classes in favor of FeedsResult classes. + - Variable 'feeds_node_batch_size' is now called 'feeds_process_limit'. + - Signature of FeedsParser::getSourceElement() changed. + - Signature of FeedsProcessor::uniqueTargets() changed. + - Signature of FeedsProcessor::existingItemId() changed. + - Sigature for callbacks registered by hook_feeds_parser_sources_alter() + changed. + - Return value of FeedsFetcher::fetch() changed. + - Signature and return value of FeedsParser::parse() changed. + - Signature of FeedsProcessor::process() changed. + - Signature of hook_feeds_after_parse() changed. + - Signature of hook_feeds_after_import() changed. + - Signature of hook_feeds_after_clear() changed. + +Feeds 7.x 1.0 Alpha 1, 2010-09-21 +--------------------------------- + +Equal to http://github.com/lxbarth/Feeds/commits/DRUPAL-7--1-0-alpha1 + +- Expire files returned by FeedsImportBatch after DRUPAL_MAXIMUM_TEMP_FILE_AGE + seconds. +- FeedsFileFetcher: track uploaded files, delete unused files. +- yhahn: Upgrade FeedsTaxonomyProcessor. +- Remove handling of target items that are array. All target items must be + objects now. +- Upgrade file and image mapper. +- Upgrade taxonomy mapper. +- Upgrade field mapper. +- Move plugin handling into FeedsPlugin class. +- Base level upgrade. + +Feeds 6.x 1.0 Beta 6, 2010-09-16 +-------------------------------- + +- #623432 Alex UA, dixon_, pvhee, cglusky, alex_b et al.: Mapper for emfield. +- #913672 andrewlevine: Break out CSV Parser into submethods so it is more + easily overridable +- #853974 snoldak924, alex_b: Fix XSS vulnerabilities in module. +- #887846 ekes: Make FeedsSimplePieEnclosure (un)serialization safe. +- #908582 XiaN Vizjereij, alex_b: Fix "Cannot use object of type stdClass as + array" error in mappers/taxonomy.inc. +- #906654 alex_b: Fix phantom subscriptions. +- #867892 alex_b: PubSubHubbub - slow down import frequency of feeds that are + subscribed to hub. +- #908964 alex_b: Break out scheduler. Note: Features depends on Job Scheduler + module now: http://drupal.org/project/job_scheduler +- #663860 funkmasterjones, infojunkie, alex_b et. al.: hook_feeds_after_parse(). +- #755556 Monkey Master, andrewlevine, alex_b: Support saving local files in + filefields. +- #891982 bangpound, twistor: Support Link 2.x. +- #870278 budda: Fix SQL query in taxonomy_get_term_by_name_vid(). +- #795114 budda, alex_b: Taxonomy term processor doesn't require vocabulary to + be set. + +Feeds 6.x 1.0 Beta 5, 2010-09-10 +-------------------------------- + +- #849840 adityakg, rbayliss, alex_b: Submit full mapping on every submission. +- #849834 rbayliss, alex_b: Generalize feeds_config_form() to feeds_form(). +- #907064 alex_b: Track imported terms. +- #906720 alex_b: Introduce a hook_feeds_after_clear(). +- #905820 tristan.oneil: Adjust delete message in FeedsDataProcessor to avoid + misleading total numbers. +- #671538 mburak: Use CURLOPT_TIMEOUT to limit download time of feeds. +- #878002 Will White, David Goode: Support multiple sources per mapping target + in FeedsDataProcessor. +- #904804 alex_b: Support exportable vocabularies. +- #836876 rsoden, Will White, alex_b: Add simple georss support to Common + Syndication Parser. +- #889196 David Goode: Support for non-numeric vocabulary IDs for feature-based + vocabularies. +- #632920 nickbits, dixon_, David Goode, alex_b et al: Inherit OG, taxonomy, + language, user properties from parent feed node. Note: Signatures of + FeedsProcessor::map(), existingItemId(), FeedsParser::getSourceElement() + changed. +- #897258 TrevorBradley, alex_b: Mapping target nid. +- #873198 BWPanda, morningtime: Import multiple values to tag vocabulary. +- #872772 andrewlevine: Fix buildNode() (and node_load()) called unnecessarily. +- #873240 thsutton: Use isset() to avoid notices. +- #878528 Sutharsan: Don't show file in UI if file does not exist. +- #901798 alex_b: Fix time off in SitemapParser. +- #885724 eliotttf: Avoid array_flip() on non scalars. +- #885052 Hanno: Fix small typo in access rights. +- #863494 ekes, alex_b: Delete temporary enclosures file. +- #851194 alex_b: Featurize - move default functionality from feeds_defaults to + feeds_fast_news, feeds_import and feeds_news features. +- #617486 alex_b: Create link to original source, view of items on feed nodes. +- #849986 lyricnz, alex_b: Cleaner batch support. +- #866492 lyricnz: Clean up tests. +- #862444 pounard: Do not name files after their enclosure class. +- #851570 morningtime: Avoid trailing slashes when passing file paths to + file_check_directory(). +- #836090 andrewlevine, alex_b: Include mapping configuration in hash. +- #853156 alex_b: Support real updates of terms. +- #858684 alex_b: Fix notices when file not found. + +Feeds 6.x 1.0 Beta 4, 2010-07-25 +-------------------------------- + +- #838018-12 Remove Formatted Number CCK mapper, cannot be properly tested, see + #857928. + +Feeds 6.x 1.0 Beta 3, 2010-07-18 +-------------------------------- + +- #854628 DanielJohnston, alex_b: Fix user processor assigns all roles. +- #838018 infojunkie: Mapper for Formatted Number CCK field. +- #856408 c.ex: Pass all $targets for hook_feeds_node_processor_targets_alter() + by reference. +- #853194 andrewlevine, alex_b: Mapping: don't reset all targets. +- #853144 alex_b: Consistent use of "replace" vs "update". +- #850998 alex_b: Clean up file upload form. Note: If you supply file paths + directly in the textfield rather than uploading them through the UI, you will + have to adjust your importer's File Fetcher settings. +- #850652 alex_b: Make ParserCSV (instead of FeedsCSVParser) populate column + names. +- #850638 alex_b: Introduce FeedsSource::preview(). +- #850298 alex_b: ParserCSV: Support batching (only affects library, full parser + level batch support to be added later with #744660). +- Minor cleanup of admin UI language and CSS. +- #647222 cglusky, jeffschuler: Specify input format for feed items. + +Feeds 6.x 1.0 Beta 2, 2010-07-10 +-------------------------------- + +- #753426 Monkey Master, andrewlevine, alex_b: Partial update of nodes. +- #840626 andrewlevine, alex_b: Support using same mapping target multiple + times. +- #624464 lyricnz, alex_b: Fix to "support tabs as delimiters". +- #840350 lyricnz: (Optionally) Transliterate enclosure filenames to provide + protection from awkward names. +- #842040 dixon_: Accept all responses from the 2xx status code series. +- #836982 Steven Merrill: Fix Feeds.module tests do not work when run from the + command line. + +Feeds 6.x 1.0 Beta 1, 2010-06-23 +-------------------------------- + +Feeds 6.x 1.0 Alpha 16, 2010-06-19 +---------------------------------- + +- #830438 andrewlevine: More secret files in FeedsImportBatch::getFilePath(). +- #759302 rjb, smartinm, et. al: Fix user warning: Duplicate entry. +- #819876 alex_b: Fix field 'url' and 'guid' don't have default values. +- #623444 mongolito404, pvhee, pdrake, servantleader, alex_b et. al.: Mapper for + link module. +- #652180 ronald_istos, rjbrown99, et. al.: Assign author of imported nodes. +- #783098 elliotttf: Introduce hook_feeds_user_processor_targets_alter(), mapper + for user profile fields. + +Feeds 6.x 1.0 Alpha 15, 2010-05-16 +---------------------------------- + +- #791296 B-Prod: Fix Feeds data processor does update id 0. +- #759904 lyricnz: Provide a Google Sitemap Parser. +- #774858 rjbrown99: Fix Node Processor updates node "created" time when + updating. +- #704236 jerdavis: Support mapping to CCK float field. +- #783820 klonos: Fix warning: copy() [function.copy]: Filename cannot be empty + in FeedsParser.inc on line 168. +- #778416 clemens.tolboom: Better message when plugin is missing. +- #760140 lyricnz: FeedsBatch->total not updated when addItem($item) is called. +- #755544 Monkey Master: Keep batch processing when mapping fails. +- alex_b: Reset import schedule after deleting items from feed. +- #653412 rbrandon: Do not create items older than expiry time. +- #725392 nicholasThompson: FeedsBatch does not check feeds folder exists before + uploading. +- #776972 lyricnz: Messages use plural when describing single item. +- #701390 frega, morningtime, Mixologic, alex_b et. al.: Fix RSS 1.0 parsing + and add basic test framework for common_syndication_parser. +- #781058 blakehall: Create teaser for imported nodes. NOTE: this may mean that + your existing installation has shorter node teasers as expected. If this is + the case, increase "Length of trimmed posts" on admin/content/node-settings. +- #622932-30 mikl: fix remaining non-standard SQL. +- #624464 bangpound: Support tabs as delimiters. + +Feeds 6.x 1.0 Alpha 14, 2010-04-11 +---------------------------------- + +- #758664: Fix regression introduced with #740962. + +Feeds 6.x 1.0 Alpha 13, 2010-03-30 +---------------------------------- + +- #622932 pounard: Fix SQL capitalization. +- #622932 pounard: Fix non-standard SQL (PostgreSQL compatibility) +- #705872 Scott Reynolds: Added HTTPFetcher autodiscovery +- #740962 alex_b: Fix FileFetcher Attached to Feed Node, Upload Field Not Saving + File Path. +- #754938 Monkey Master: FeedsCSVParser.inc uses strtolower() while parsing + UTF-8 files. +- #736684 Souvent22, Mixologic: FeedsDateTime & Batch DateTime causes core + dumps. +- #750168 jtr: _parser_common_syndication_title does not strip html tags before + constructing a title. +- #648080 pvhee: FeedsNodeProcessor - static caching of mapping targets makes + mapping fail with multiple feed configurations. +- #735444 Doug Preble: PubSubHubbub - Fix "Subscription refused by callback URL" + with PHP 5.2.0. +- alex_b: Suppress namespace warnings when parsing feeds for subscription in + PuSHSubscriber.inc +- #724184 ekes: catch failures when parsing for PubSubHubbub hub and self. +- #706984 lyricnz: Add FeedsSimplePie::parseExtensions() to allow parsing to be + customized. +- #728854 Scott Reynolds: Fix $queue->createItem() fails. +- #707098 alex_b: Improve performance of nodeapi and access checks. +- #726012 alex_b: Fix RSS descriptions not being reset in + common_syndication_parser.inc. +- alex_b: Fix a typo in the return value of process() in FeedsTermProcessor. +- alex_b: Stop PubSubHubbub from subscribing if it is not enabled. +- #711664 neclimdul: guarantee compatibility with CTools 1.4 by declaring that + Feeds uses hooks to define plugins via hook_ctools_plugin_plugins(). +- #718460 jerdavis: In FeedsNodeProcessor, clear items only for the current + importer id. +- #718474 jerdavis: In FeedsNodeProcessor, check for duplicate items within + same importer id. + +Feeds 6.x 1.0 Alpha 12, 2010-02-23 +---------------------------------- + +- #600584 alex_b: PubSubHubbub support. +- alex_b: Debug log. +- alex_b: Add sourceSave() and sourceDelete() methods notifying plugin + implementers of a source being saved or deleted. +- #717168 nicholasThompson: Fix feeds UI JS doesn't select labels correctly. +- #708228 Scott Reynolds, alex_b: Break FeedsImportBatch into separate classes. + NOTE: Review your FeedsFetcher implementation for changes in the + FeedsImportBatch class, small adjustments may be necessary. +- alex_b: Support mapping to OpenID, using OpenID as a unique mapping target. +- alex_b: Handle exceptions outside of Importer/Source facade methods. +- #600584 alex_b: Use Batch API. + NOTE: third party plugins/extensions implementing FeedsProcessor::process(), + FeedsProcessor::clear() or FeedsImporter::expire() need to adjust their + implementations. Modules that directly use Feeds' API for importing or + clearing sources need may want to use feeds_batch_set() instead of + feeds_source()->import() or feeds_source()->clear(). + +Feeds 6.x 1.0 Alpha 11, 2010-02-10 +---------------------------------- + +- #701432 pounard, Will White: Fix array_shift() expects parameter 1 is Array + error. Note: Parsers are responsible to ensure that the parameter passed to + FeedsImportBatch::setItems() is an Array. +- #698356 alex_b: Refactor and clean up FeedsScheduler::work() to allow more + scheduled tasks than 'import' and 'expire'. + +Feeds 6.x 1.0 Alpha 10, 2010-01-25 +---------------------------------- + +- #647128 bigkevmcd, Michelle: Fix broken author info in FeedsSyndicationParser. +- alex_b: Add mapping API for FeedsDataProcessor. +- alex_b: Decode HTML entities for title and author name in + FeedsSimplePieParser. +- #623448 David Goode, alex_b, et al.: Date mapper. +- #624088 mongolito404, David Goode, alex_b: Imagefield/filefield mapper, + formalize feed elements. +- #584034 aaroncouch, mongolito404: Views integration. +- Redirect to node or import form after manual import or delete. +- #663830 Aron Novak, alex_b: When download of URL failed, node w/ empty title + is created. +- #654728 Aron Novak: Fix parsing + data handling error with RDF 1.0 feeds. +- #641522 mongolito404, alex_b: Consolidate import stage results. +- #662104 Aron Novak: Specify PHP requirement in .info file. +- #657374 dtomasch: Common Parser does not get RSS Authors correctly. + +Feeds 6.x 1.0 Alpha 9, 2009-12-14 +--------------------------------- + +- API change: feeds_source() takes an FeedsImporter id instead of an importer, + the methods import() and clear() moved from FeedsImporter to FeedsSource. + Import from a source with feeds_source($id, $nid)->import(); +- #629096 quickcel: Fix underscores in feed creation link. +- #652848 BWPanda: Add 'clear-block' to admin-ui to fix float issues. +- #623424 Kars-T, Eugen Mayer, alex_b: Mapper for Taxonomy. +- #649552 rsoden: Provide variable for data table name. +- #631962 velosol, alex_b: FeedsNodeProcessor: Update when changed. +- #623452 mongolito404: Port basic test infrastructure for mappers, test for + basic CCK mapper. + +Feeds 6.x 1.0 Alpha 8, 2009-11-18 +--------------------------------- + +- #634886 Kars-T, EugenMayer: Add vid to node process functions. +- #613494 miasma: Remove length limit from URL. +- #631050 z.stolar: Add feed_nid on node_load of a feed item. +- #631248 velosol: Set log message when creating a node in FeedsNodeProcessor. + +Feeds 6.x 1.0 Alpha 7, 2009-11-04 +--------------------------------- + +- #622654 Don't show body as option for mapper when body is disabled +- Allow cURL only to download via http or https +- Throw an exception in FeedsHTTPFetcher if result is not 200 + +Feeds 6.x 1.0 Alpha 6, 2009-11-03 +--------------------------------- + +- Split number of items to queue on cron from feeds_schedule_num variable + (see README.txt) +- #619110 Fix node_delete() in FeedsNodeProcessor +- Add descriptions to all mapping sources and targets + +Feeds 6.x 1.0 Alpha 5, 2009-10-23 +--------------------------------- + +- #584500 Add Feeds default module + +Feeds 6.x 1.0 Alpha 4, 2009-10-21 +--------------------------------- + +- Initial release diff --git a/sites/all/modules/feeds/LICENSE.txt b/sites/all/modules/feeds/LICENSE.txt new file mode 100644 index 0000000000000000000000000000000000000000..d159169d1050894d3ea3b98e1c965c4058208fe1 --- /dev/null +++ b/sites/all/modules/feeds/LICENSE.txt @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + <signature of Ty Coon>, 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/sites/all/modules/feeds/README.txt b/sites/all/modules/feeds/README.txt new file mode 100644 index 0000000000000000000000000000000000000000..5c137a2f66725f103e961f3ee54410ded735791a --- /dev/null +++ b/sites/all/modules/feeds/README.txt @@ -0,0 +1,204 @@ + + +"It feeds" + + +FEEDS +===== + +An import and aggregation framework for Drupal. +http://drupal.org/project/feeds + +Features +======== + +- Pluggable import configurations consisting of fetchers (get data) parsers + (read and transform data) and processors (create content on Drupal). +-- HTTP upload (with optional PubSubHubbub support). +-- File upload. +-- CSV, RSS, Atom parsing. +-- Creates nodes or terms. +-- Creates lightweight database records if Data module is installed. + http://drupal.org/project/data +-- Additional fetchers/parsers or processors can be added by an object oriented + plugin system. +-- Granular mapping of parsed data to content elements. +- Import configurations can be piggy backed on nodes (thus using nodes to track + subscriptions to feeds) or they can be used on a standalone form. +- Unlimited number of import configurations. +- Export import configurations to code. +- Optional libraries module support. + +Requirements +============ + +- CTools 1.x + http://drupal.org/project/ctools +- Job Scheduler + http://drupal.org/project/job_scheduler +- Drupal 7.x + http://drupal.org/project/drupal +- PHP safe mode is not supported, depending on your Feeds Importer configuration + safe mode may cause no problems though. + +Installation +============ + +- Install Feeds, Feeds Admin UI. +- To get started quick, install one or all of the following Feature modules: + Feeds News, Feeds Import, Feeds Fast News (more info below). +- Make sure cron is correctly configured http://drupal.org/cron +- Go to import/ to import data. +- To use SimplePie parser, download either the compiled or minified SimplePie + and place simplepie_[version].compiled.php into feeds/libraries as + simplepie.compiled.php. Recommended version: 1.3. + http://simplepie.org/ + +Feature modules +=============== + +Feeds ships with three feature modules that can be enabled on +admin/build/modules or - if you are using Features - on admin/build/features. +http://drupal.org/project/features + +The purpose of these modules is to provide a quick start for using Feeds. You +can either use them out of the box as they come or you can take them as samples +to learn how to build import or aggregation functionality with Feeds. + +The feature modules merely contain sets of configurations using Feeds and in +some cases the modules Node, Views or Data. If the default configurations do not +fit your use case you can change them on the respective configuration pages for +Feeds, Node, Views or Data. + +Here is a description of the provided feature modules: + +- Feeds News - + +This feature is a news aggregator. It provides a content type "Feed" that can +be used to subscribe to RSS or Atom feeds. Every item on such a feed is +aggregated as a node of the type "Feed item", also provided by the module. + +What's neat about Feeds News is that it comes with a configured View that shows +a list of news items with every feed on the feed node's "View items" tab. It +also comes with an OPML importer filter that can be accessed under /import. + +- Feeds Fast News - + +This feature is very similar to Feeds News. The big difference is that instead +of aggregating a node for every item on a feed, it creates a database record +in a single table, thus significantly improving performance. This approach +especially starts to save resources when many items are being aggregated and +expired (= deleted) on a site. + +- Feeds Import - + +This feature is an example illustrating Feeds' import capabilities. It contains +a node importer and a user importer that can be accessed under /import. Both +accept CSV or TSV files as imports. + +PubSubHubbub support +==================== + +Feeds supports the PubSubHubbub publish/subscribe protocol. Follow these steps +to set it up for your site. +http://code.google.com/p/pubsubhubbub/ + +- Go to admin/build/feeds and edit (override) the importer configuration you + would like to use for PubSubHubbub. +- Choose the HTTP Fetcher if it is not already selected. +- On the HTTP Fetcher, click on 'settings' and check "Use PubSubHubbub". +- Optionally you can use a designated hub such as http://superfeedr.com/ or your + own. If a designated hub is specified, every feed on this importer + configuration will be subscribed to this hub, no matter what the feed itself + specifies. + +Libraries support +================= + +If you are using Libraries module, you can place external libraries in the +Libraries module's search path (for instance sites/all/libraries. The only +external library used at the moment is SimplePie. + +Libraries found in the libraries search path are preferred over libraries in +feeds/libraries/. + +Transliteration support +======================= + +If you plan to store files with Feeds - for instance when storing podcasts +or images from syndication feeds - it is recommended to enable the +Transliteration module to avoid issues with non-ASCII characters in file names. +http://drupal.org/project/transliteration + +API Overview +============ + +See "The developer's guide to Feeds": +http://drupal.org/node/622700 + +Testing +======= + +See "The developer's guide to Feeds": +http://drupal.org/node/622700 + +Debugging +========= + +Set the Drupal variable 'feeds_debug' to TRUE (i. e. using drush). This will +create a file /tmp/feeds_[my_site_location].log. Use "tail -f" on the command +line to get a live view of debug output. + +Note: at the moment, only PubSubHubbub related actions are logged. + +Performance +=========== + +See "The site builder's guide to Feeds": +http://drupal.org/node/622698 + +Hidden settings +=============== + +Hidden settings are variables that you can define by adding them to the $conf +array in your settings.php file. + +Name: feeds_debug +Default: FALSE +Description: Set to TRUE for enabling debug output to + /DRUPALTMPDIR/feeds_[sitename].log + +Name: feeds_importer_class +Default: 'FeedsImporter' +Description: The class to use for importing feeds. + +Name: feeds_source_class +Default: 'FeedsSource' +Description: The class to use for handling feed sources. + +Name: feeds_data_$importer_id +Default: feeds_data_$importer_id +Description: The table used by FeedsDataProcessor to store feed items. Usually a + FeedsDataProcessor builds a table name from a prefix (feeds_data_) + and the importer's id ($importer_id). This default table name can + be overridden by defining a variable with the same name. + +Name: feeds_process_limit +Default: 50 + The number of nodes feed node processor creates or deletes in one + page load. + +Name: http_request_timeout +Default: 15 +Description: Timeout in seconds to wait for an HTTP get request to finish. + +Name: feeds_never_use_curl +Default: FALSE +Description: Flag to stop feeds from using its cURL for http requests. See + http_request_use_curl(). + +Glossary +======== + +See "Feeds glossary": +http://drupal.org/node/622710 diff --git a/sites/all/modules/feeds/feeds.api.php b/sites/all/modules/feeds/feeds.api.php new file mode 100644 index 0000000000000000000000000000000000000000..0154e9011bb479aed4f4c6eb8b184a311691b7f0 --- /dev/null +++ b/sites/all/modules/feeds/feeds.api.php @@ -0,0 +1,301 @@ +<?php + +/** + * @file + * Documentation of Feeds hooks. + */ + +/** + * Feeds offers a CTools based plugin API. Fetchers, parsers and processors are + * declared to Feeds as plugins. + * + * @see feeds_feeds_plugins() + * @see FeedsFetcher + * @see FeedsParser + * @see FeedsProcessor + * + * @defgroup pluginapi Plugin API + * @{ + */ + +/** + * Example of a CTools plugin hook that needs to be implemented to make + * hook_feeds_plugins() discoverable by CTools and Feeds. The hook specifies + * that the hook_feeds_plugins() returns Feeds Plugin API version 1 style + * plugins. + */ +function hook_ctools_plugin_api($owner, $api) { + if ($owner == 'feeds' && $api == 'plugins') { + return array('version' => 1); + } +} + +/** + * A hook_feeds_plugins() declares available Fetcher, Parser or Processor + * plugins to Feeds. For an example look at feeds_feeds_plugin(). For exposing + * this hook hook_ctools_plugin_api() MUST be implemented, too. + * + * @see feeds_feeds_plugin() + */ +function hook_feeds_plugins() { + $info = array(); + $info['MyFetcher'] = array( + 'name' => 'My Fetcher', + 'description' => 'Fetches my stuff.', + 'help' => 'More verbose description here. Will be displayed on fetcher selection menu.', + 'handler' => array( + 'parent' => 'FeedsFetcher', + 'class' => 'MyFetcher', + 'file' => 'MyFetcher.inc', + 'path' => drupal_get_path('module', 'my_module'), // Feeds will look for MyFetcher.inc in the my_module directory. + ), + ); + $info['MyParser'] = array( + 'name' => 'ODK parser', + 'description' => 'Parse my stuff.', + 'help' => 'More verbose description here. Will be displayed on parser selection menu.', + 'handler' => array( + 'parent' => 'FeedsParser', // Being directly or indirectly an extension of FeedsParser makes a plugin a parser plugin. + 'class' => 'MyParser', + 'file' => 'MyParser.inc', + 'path' => drupal_get_path('module', 'my_module'), + ), + ); + $info['MyProcessor'] = array( + 'name' => 'ODK parser', + 'description' => 'Process my stuff.', + 'help' => 'More verbose description here. Will be displayed on processor selection menu.', + 'handler' => array( + 'parent' => 'FeedsProcessor', + 'class' => 'MyProcessor', + 'file' => 'MyProcessor.inc', + 'path' => drupal_get_path('module', 'my_module'), + ), + ); + return $info; +} + +/** + * @} + */ + +/** + * @defgroup import Import and clear hooks + * @{ + */ + +/** + * Invoked after a feed source has been parsed, before it will be processed. + * + * @param $source + * FeedsSource object that describes the source that has been imported. + * @param $result + * FeedsParserResult object that has been parsed from the source. + */ +function hook_feeds_after_parse(FeedsSource $source, FeedsParserResult $result) { + // For example, set title of imported content: + $result->title = 'Import number ' . my_module_import_id(); +} + +/** + * Invoked before a feed item is saved. + * + * @param $source + * FeedsSource object that describes the source that is being imported. + * @param $entity + * The entity object. + * @param $item + * The parser result for this entity. + */ +function hook_feeds_presave(FeedsSource $source, $entity, $item) { + if ($entity->feeds_item->entity_type == 'node') { + // Skip saving this entity. + $entity->feeds_item->skip = TRUE; + } +} + +/** + * Invoked after a feed source has been imported. + * + * @param $source + * FeedsSource object that describes the source that has been imported. + */ +function hook_feeds_after_import(FeedsSource $source) { + // See geotaxonomy module's implementation for an example. +} + +/** + * Invoked after a feed source has been cleared of its items. + * + * @param $source + * FeedsSource object that describes the source that has been cleared. + */ +function hook_feeds_after_clear(FeedsSource $source) { +} + +/** + * @} + */ + +/** + * @defgroup mappingapi Mapping API + * @{ + */ + +/** + * Alter mapping sources. + * + * Use this hook to add additional mapping sources for any parser. Allows for + * registering a callback to be invoked at mapping time. + * + * @see my_source_get_source(). + * @see locale_feeds_parser_sources_alter(). + */ +function hook_feeds_parser_sources_alter(&$sources, $content_type) { + $sources['my_source'] = array( + 'name' => t('Images in description element'), + 'description' => t('Images occuring in the description element of a feed item.'), + 'callback' => 'my_source_get_source', + ); +} + +/** + * Example callback specified in hook_feeds_parser_sources_alter(). + * + * To be invoked on mapping time. + * + * @param $source + * The FeedsSource object being imported. + * @param $result + * The FeedsParserResult object being mapped from. + * @param $key + * The key specified in the $sources array in + * hook_feeds_parser_sources_alter(). + * + * @return + * The value to be extracted from the source. + * + * @see hook_feeds_parser_sources_alter(). + * @see locale_feeds_get_source(). + */ +function my_source_get_source($source, FeedsParserResult $result, $key) { + $item = $result->currentItem(); + return my_source_parse_images($item['description']); +} + +/** + * Alter mapping targets for entities. Use this hook to add additional target + * options to the mapping form of Node processors. + * + * If the key in $targets[] does not correspond to the actual key on the node + * object ($node->key), real_target MUST be specified. See mappers/link.inc + * + * For an example implementation, see mappers/content.inc + * + * @param &$targets + * Array containing the targets to be offered to the user. Add to this array + * to expose additional options. Remove from this array to suppress options. + * Remove with caution. + * @param $entity_type + * The entity type of the target, for instance a 'node' entity. + * @param $bundle_name + * The bundle name for which to alter targets. + */ +function hook_feeds_processor_targets_alter(&$targets, $entity_type, $bundle_name) { + if ($entity_type == 'node') { + $targets['my_node_field'] = array( + 'name' => t('My custom node field'), + 'description' => t('Description of what my custom node field does.'), + 'callback' => 'my_module_set_target', + + // Specify both summary_callback and form_callback to add a per mapping + // configuration form. + 'summary_callback' => 'my_module_summary_callback', + 'form_callback' => 'my_module_form_callback', + ); + $targets['my_node_field2'] = array( + 'name' => t('My Second custom node field'), + 'description' => t('Description of what my second custom node field does.'), + 'callback' => 'my_module_set_target2', + 'real_target' => 'my_node_field_two', // Specify real target field on node. + ); + } +} + +/** + * Example callback specified in hook_feeds_processor_targets_alter(). + * + * @param $source + * Field mapper source settings. + * @param $entity + * An entity object, for instance a node object. + * @param $target + * A string identifying the target on the node. + * @param $value + * The value to populate the target with. + * @param $mapping + * Associative array of the mapping settings from the per mapping + * configuration form. + */ +function my_module_set_target($source, $entity, $target, $value, $mapping) { + $entity->{$target}[$entity->language][0]['value'] = $value; + if (isset($source->importer->processor->config['input_format'])) { + $entity->{$target}[$entity->language][0]['format'] = + $source->importer->processor->config['input_format']; + } +} + +/** + * Example of the summary_callback specified in + * hook_feeds_processor_targets_alter(). + * + * @param $mapping + * Associative array of the mapping settings. + * @param $target + * Array of target settings, as defined by the processor or + * hook_feeds_processor_targets_alter(). + * @param $form + * The whole mapping form. + * @param $form_state + * The form state of the mapping form. + * + * @return + * Returns, as a string that may contain HTML, the summary to display while + * the full form isn't visible. + * If the return value is empty, no summary and no option to view the form + * will be displayed. + */ +function my_module_summary_callback($mapping, $target, $form, $form_state) { + if (empty($mapping['my_setting'])) { + return t('My setting <strong>not</strong> active'); + } + else { + return t('My setting <strong>active</strong>'); + } +} + +/** + * Example of the form_callback specified in + * hook_feeds_processor_targets_alter(). + * + * The arguments are the same that my_module_summary_callback() gets. + * + * @see my_module_summary_callback() + * + * @return + * The per mapping configuration form. Once the form is saved, $mapping will + * be populated with the form values. + */ +function my_module_form_callback($mapping, $target, $form, $form_state) { + return array( + 'my_setting' => array( + '#type' => 'checkbox', + '#title' => t('My setting checkbox'), + '#default_value' => !empty($mapping['my_setting']), + ), + ); +} + +/** + * @} + */ diff --git a/sites/all/modules/feeds/feeds.css b/sites/all/modules/feeds/feeds.css new file mode 100644 index 0000000000000000000000000000000000000000..a5376e780ff732e0a6d8e6e6ad38458258b492a7 --- /dev/null +++ b/sites/all/modules/feeds/feeds.css @@ -0,0 +1,7 @@ + +#edit-feeds-FeedsFileFetcher-upload-wrapper .file-info { + float: left; + width: 200px; + border-right: 1px solid #ddd; + margin-right: 10px; + } diff --git a/sites/all/modules/feeds/feeds.info b/sites/all/modules/feeds/feeds.info new file mode 100644 index 0000000000000000000000000000000000000000..01b1d0ffa274b7c24c484188eb4320e3733ffa7d --- /dev/null +++ b/sites/all/modules/feeds/feeds.info @@ -0,0 +1,43 @@ +name = Feeds +description = Aggregates RSS/Atom/RDF feeds, imports CSV files and more. +package = Feeds +core = 7.x +dependencies[] = ctools +dependencies[] = job_scheduler + +files[] = includes/FeedsConfigurable.inc +files[] = includes/FeedsImporter.inc +files[] = includes/FeedsSource.inc +files[] = libraries/ParserCSV.inc +files[] = libraries/http_request.inc +files[] = libraries/PuSHSubscriber.inc +files[] = tests/feeds.test +files[] = tests/feeds_date_time.test +files[] = tests/feeds_mapper_date.test +files[] = tests/feeds_mapper_field.test +files[] = tests/feeds_mapper_file.test +files[] = tests/feeds_mapper_path.test +files[] = tests/feeds_mapper_profile.test +files[] = tests/feeds_mapper.test +files[] = tests/feeds_mapper_config.test +files[] = tests/feeds_fetcher_file.test +files[] = tests/feeds_processor_node.test +files[] = tests/feeds_processor_term.test +files[] = tests/feeds_processor_user.test +files[] = tests/feeds_scheduler.test +files[] = tests/feeds_mapper_link.test +files[] = tests/feeds_mapper_taxonomy.test +files[] = tests/parser_csv.test +files[] = views/feeds_views_handler_argument_importer_id.inc +files[] = views/feeds_views_handler_field_importer_name.inc +files[] = views/feeds_views_handler_field_log_message.inc +files[] = views/feeds_views_handler_field_severity.inc +files[] = views/feeds_views_handler_field_source.inc +files[] = views/feeds_views_handler_filter_severity.inc + +; Information added by drupal.org packaging script on 2012-10-24 +version = "7.x-2.0-alpha7" +core = "7.x" +project = "feeds" +datestamp = "1351111319" + diff --git a/sites/all/modules/feeds/feeds.install b/sites/all/modules/feeds/feeds.install new file mode 100644 index 0000000000000000000000000000000000000000..7584f4e120750ad9d60abc883d71039eb02434b4 --- /dev/null +++ b/sites/all/modules/feeds/feeds.install @@ -0,0 +1,563 @@ +<?php + +/** + * @file + * Schema definitions install/update/uninstall hooks. + */ + +/** + * Implements hook_schema(). + */ +function feeds_schema() { + $schema = array(); + + $schema['feeds_importer'] = array( + 'description' => 'Configuration of feeds objects.', + 'export' => array( + 'key' => 'id', + 'identifier' => 'feeds_importer', + 'default hook' => 'feeds_importer_default', // Function hook name. + 'api' => array( + 'owner' => 'feeds', + 'api' => 'feeds_importer_default', // Base name for api include files. + 'minimum_version' => 1, + 'current_version' => 1, + ), + ), + 'fields' => array( + 'id' => array( + 'type' => 'varchar', + 'length' => 128, + 'not null' => TRUE, + 'default' => '', + 'description' => 'Id of the fields object.', + ), + 'config' => array( + 'type' => 'blob', + 'size' => 'big', + 'not null' => FALSE, + 'description' => 'Configuration of the feeds object.', + 'serialize' => TRUE, + ), + ), + 'primary key' => array('id'), + ); + $schema['feeds_source'] = array( + 'description' => 'Source definitions for feeds.', + 'fields' => array( + 'id' => array( + 'type' => 'varchar', + 'length' => 128, + 'not null' => TRUE, + 'default' => '', + 'description' => 'Id of the feed configuration.', + ), + 'feed_nid' => array( + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'unsigned' => TRUE, + 'description' => 'Node nid if this particular source is attached to a feed node.', + ), + 'config' => array( + 'type' => 'blob', + 'size' => 'big', + 'not null' => FALSE, + 'description' => 'Configuration of the source.', + 'serialize' => TRUE, + ), + 'source' => array( + 'type' => 'text', + 'not null' => TRUE, + 'description' => 'Main source resource identifier. E. g. a path or a URL.', + ), + 'state' => array( + 'type' => 'blob', + 'size' => 'big', + 'not null' => FALSE, + 'description' => 'State of import or clearing batches.', + 'serialize' => TRUE, + ), + 'fetcher_result' => array( + 'type' => 'blob', + 'size' => 'big', + 'not null' => FALSE, + 'description' => 'Cache for fetcher result.', + 'serialize' => TRUE, + ), + 'imported' => array( + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'unsigned' => TRUE, + 'description' => 'Timestamp when this source was imported last.', + ), + ), + 'primary key' => array('id', 'feed_nid'), + 'indexes' => array( + 'id' => array('id'), + 'feed_nid' => array('feed_nid'), + 'id_source' => array('id', array('source', 128)), + ), + ); + $schema['feeds_item'] = array( + 'description' => 'Tracks items such as nodes, terms, users.', + 'fields' => array( + 'entity_type' => array( + 'type' => 'varchar', + 'length' => 32, + 'not null' => TRUE, + 'default' => '', + 'description' => 'The entity type.', + ), + 'entity_id' => array( + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'The imported entity\'s serial id.', + ), + 'id' => array( + 'type' => 'varchar', + 'length' => 128, + 'not null' => TRUE, + 'default' => '', + 'description' => 'The id of the importer that created this item.', + ), + 'feed_nid' => array( + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'Node id of the source, if available.', + ), + 'imported' => array( + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'description' => 'Import date of the feed item, as a Unix timestamp.', + ), + 'url' => array( + 'type' => 'text', + 'not null' => TRUE, + 'description' => 'Link to the feed item.', + ), + 'guid' => array( + 'type' => 'text', + 'not null' => TRUE, + 'description' => 'Unique identifier for the feed item.' + ), + 'hash' => array( + 'type' => 'varchar', + 'length' => 32, // The length of an MD5 hash. + 'not null' => TRUE, + 'default' => '', + 'description' => 'The hash of the source item.', + ), + ), + 'primary key' => array('entity_type', 'entity_id'), + 'indexes' => array( + 'id' => array('id'), + 'feed_nid' => array('feed_nid'), + 'lookup_url' => array('entity_type', 'id', 'feed_nid', array('url', 128)), + 'lookup_guid' => array('entity_type', 'id', 'feed_nid', array('guid', 128)), + 'global_lookup_url' => array('entity_type', array('url', 128)), + 'global_lookup_guid' => array('entity_type', array('guid', 128)), + 'imported' => array('imported'), + ), + ); + $schema['feeds_push_subscriptions'] = array( + 'description' => 'PubSubHubbub subscriptions.', + 'fields' => array( + 'domain' => array( + 'type' => 'varchar', + 'length' => 128, + 'not null' => TRUE, + 'default' => '', + 'description' => 'Domain of the subscriber. Corresponds to an importer id.', + ), + 'subscriber_id' => array( + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'unsigned' => TRUE, + 'description' => 'ID of the subscriber. Corresponds to a feed nid.', + ), + 'timestamp' => array( + 'type' => 'int', + 'unsigned' => FALSE, + 'default' => 0, + 'not null' => TRUE, + 'description' => 'Created timestamp.', + ), + 'hub' => array( + 'type' => 'text', + 'not null' => TRUE, + 'description' => 'The URL of the hub endpoint of this subscription.', + ), + 'topic' => array( + 'type' => 'text', + 'not null' => TRUE, + 'description' => 'The topic URL (feed URL) of this subscription.', + ), + 'secret' => array( + 'type' => 'varchar', + 'length' => 128, + 'not null' => TRUE, + 'default' => '', + 'description' => 'Shared secret for message authentication.', + ), + 'status' => array( + 'type' => 'varchar', + 'length' => 64, + 'not null' => TRUE, + 'default' => '', + 'description' => 'Status of subscription.', + ), + 'post_fields' => array( + 'type' => 'text', + 'not null' => FALSE, + 'description' => 'Fields posted.', + 'serialize' => TRUE, + ), + ), + 'primary key' => array('domain', 'subscriber_id'), + 'indexes' => array( + 'timestamp' => array('timestamp'), + ), + ); + $schema['feeds_log'] = array( + 'description' => 'Table that contains logs of feeds events.', + 'fields' => array( + 'flid' => array( + 'type' => 'serial', + 'not null' => TRUE, + 'description' => 'Primary Key: Unique feeds event ID.', + ), + 'id' => array( + 'type' => 'varchar', + 'length' => 128, + 'not null' => TRUE, + 'default' => '', + 'description' => 'The id of the importer that logged the event.', + ), + 'feed_nid' => array( + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'Node id of the source, if available.', + ), + 'log_time' => array( + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'description' => 'Unix timestamp of when event occurred.', + ), + 'request_time' => array( + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'description' => 'Unix timestamp of the request when the event occurred.', + ), + 'type' => array( + 'type' => 'varchar', + 'length' => 64, + 'not null' => TRUE, + 'default' => '', + 'description' => 'Type of log message, for example "feeds_import"."', + ), + 'message' => array( + 'type' => 'text', + 'not null' => TRUE, + 'size' => 'big', + 'description' => 'Text of log message to be passed into the t() function.', + ), + 'variables' => array( + 'type' => 'blob', + 'not null' => TRUE, + 'size' => 'big', + 'description' => 'Serialized array of variables that match the message string and that is passed into the t() function.', + ), + 'severity' => array( + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'default' => 0, + 'size' => 'tiny', + 'description' => 'The severity level of the event; ranges from 0 (Emergency) to 7 (Debug)', + ), + ), + 'primary key' => array('flid'), + 'indexes' => array( + 'id' => array('id'), + 'id_feed_nid' => array('id', 'feed_nid'), + 'request_time' => array('request_time'), + 'log_time' => array('log_time'), + 'type' => array('type'), + ), + ); + return $schema; +} + +/** + * Rename feeds_source.batch to feeds_source.state, add slot for caching fetcher + * result. + */ +function feeds_update_7100() { + $spec = array( + 'type' => 'text', + 'size' => 'big', + 'not null' => FALSE, + 'description' => 'State of import or clearing batches.', + 'serialize' => TRUE, + ); + db_change_field('feeds_source', 'batch', 'state', $spec); + + $spec = array( + 'type' => 'text', + 'size' => 'big', + 'not null' => FALSE, + 'description' => 'Cache for fetcher result.', + 'serialize' => TRUE, + ); + db_add_field('feeds_source', 'fetcher_result', $spec); +} + +/** + * Add imported timestamp to feeds_source table. + */ +function feeds_update_7201() { + $spec = array( + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'unsigned' => TRUE, + 'description' => 'Timestamp when this source was imported last.', + ); + db_add_field('feeds_source', 'imported', $spec); +} + +/** + * Create a single feeds_item table tracking all imports. + */ +function feeds_update_7202() { + $spec = array( + 'description' => 'Tracks items such as nodes, terms, users.', + 'fields' => array( + 'entity_type' => array( + 'type' => 'varchar', + 'length' => 32, + 'not null' => TRUE, + 'default' => '', + 'description' => 'The entity type.', + ), + 'entity_id' => array( + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'The imported entity\'s serial id.', + ), + 'id' => array( + 'type' => 'varchar', + 'length' => 128, + 'not null' => TRUE, + 'default' => '', + 'description' => 'The id of the importer that created this item.', + ), + 'feed_nid' => array( + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'Node id of the source, if available.', + ), + 'imported' => array( + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'description' => 'Import date of the feed item, as a Unix timestamp.', + ), + 'url' => array( + 'type' => 'text', + 'not null' => TRUE, + 'description' => 'Link to the feed item.', + ), + 'guid' => array( + 'type' => 'text', + 'not null' => TRUE, + 'description' => 'Unique identifier for the feed item.' + ), + 'hash' => array( + 'type' => 'varchar', + 'length' => 32, // The length of an MD5 hash. + 'not null' => TRUE, + 'default' => '', + 'description' => 'The hash of the source item.', + ), + ), + 'primary key' => array('entity_type', 'entity_id'), + 'indexes' => array( + 'id' => array('id'), + 'feed_nid' => array('feed_nid'), + 'lookup_url' => array('entity_type', 'id', 'feed_nid', array('url', 128)), + 'lookup_guid' => array('entity_type', 'id', 'feed_nid', array('guid', 128)), + 'imported' => array('imported'), + ), + ); + db_create_table('feeds_item', $spec); + // Copy all existing values from old tables and drop them. + $insert = "INSERT INTO {feeds_item} (entity_type, entity_id, id, feed_nid, imported, url, guid, hash)"; + db_query($insert . " SELECT 'node', nid, id, feed_nid, imported, url, guid, hash FROM {feeds_node_item}"); + db_query($insert . " SELECT 'taxonomy_term', tid, id, feed_nid, 0, '', '', '' FROM {feeds_term_item}"); + db_drop_table('feeds_node_item'); + db_drop_table('feeds_term_item'); +} + +/** + * Add feeds_log table. + */ +function feeds_update_7203() { + $schema = array( + 'description' => 'Table that contains logs of feeds events.', + 'fields' => array( + 'flid' => array( + 'type' => 'serial', + 'not null' => TRUE, + 'description' => 'Primary Key: Unique feeds event ID.', + ), + 'id' => array( + 'type' => 'varchar', + 'length' => 128, + 'not null' => TRUE, + 'default' => '', + 'description' => 'The id of the importer that logged the event.', + ), + 'feed_nid' => array( + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'Node id of the source, if available.', + ), + 'log_time' => array( + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'description' => 'Unix timestamp of when event occurred.', + ), + 'request_time' => array( + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'description' => 'Unix timestamp of the request when the event occurred.', + ), + 'type' => array( + 'type' => 'varchar', + 'length' => 64, + 'not null' => TRUE, + 'default' => '', + 'description' => 'Type of log message, for example "feeds_import"."', + ), + 'message' => array( + 'type' => 'text', + 'not null' => TRUE, + 'size' => 'big', + 'description' => 'Text of log message to be passed into the t() function.', + ), + 'variables' => array( + 'type' => 'blob', + 'not null' => TRUE, + 'size' => 'big', + 'description' => 'Serialized array of variables that match the message string and that is passed into the t() function.', + ), + 'severity' => array( + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'default' => 0, + 'size' => 'tiny', + 'description' => 'The severity level of the event; ranges from 0 (Emergency) to 7 (Debug)', + ), + ), + 'primary key' => array('flid'), + 'indexes' => array( + 'id' => array('id'), + 'id_feed_nid' => array('id', 'feed_nid'), + 'request_time' => array('request_time'), + 'log_time' => array('log_time'), + 'type' => array('type'), + ), + ); + db_create_table('feeds_log', $schema); +} + +/** + * Add index for looking up by entity_type + url/ guid to feeds_item table. + */ +function feeds_update_7204() { + db_add_index('feeds_item', 'global_lookup_url', array('entity_type', array('url', 128))); + db_add_index('feeds_item', 'global_lookup_guid', array('entity_type', array('guid', 128))); +} + +/** + * Shorten {feeds_item}.entity_type to 32 chars and shorten relevant indexes. + */ +function feeds_update_7205() { + db_drop_primary_key('feeds_item'); + db_drop_index('feeds_item', 'lookup_url'); + db_drop_index('feeds_item', 'lookup_guid'); + db_drop_index('feeds_item', 'global_lookup_url'); + db_drop_index('feeds_item', 'global_lookup_guid'); + + db_change_field('feeds_item', 'entity_type', 'entity_type', array( + 'type' => 'varchar', + 'length' => 32, + 'not null' => TRUE, + 'default' => '', + 'description' => 'The entity type.', + )); + + db_add_primary_key('feeds_item', array('entity_type', 'entity_id')); + db_add_index('feeds_item', 'lookup_url', array('entity_type', 'id', 'feed_nid', array('url', 128))); + db_add_index('feeds_item', 'lookup_guid', array('entity_type', 'id', 'feed_nid', array('guid', 128))); + db_add_index('feeds_item', 'global_lookup_url', array('entity_type', array('url', 128))); + db_add_index('feeds_item', 'global_lookup_guid', array('entity_type', array('guid', 128))); +} + +/** + * Change state and fetcher_result fields from text to blob. + */ +function feeds_update_7206() { + db_change_field('feeds_source', 'state', 'state', array( + 'type' => 'blob', + 'size' => 'big', + 'not null' => FALSE, + 'description' => 'State of import or clearing batches.', + 'serialize' => TRUE, + )); + + db_change_field('feeds_source', 'fetcher_result', 'fetcher_result', array( + 'type' => 'blob', + 'size' => 'big', + 'not null' => FALSE, + 'description' => 'Cache for fetcher result.', + 'serialize' => TRUE, + )); +} + +/** + * Change config fields from text to big blobs. + */ +function feeds_update_7207() { + db_change_field('feeds_importer', 'config', 'config', array( + 'type' => 'blob', + 'size' => 'big', + 'not null' => FALSE, + 'description' => 'Configuration of the feeds object.', + 'serialize' => TRUE, + )); + + db_change_field('feeds_source', 'config', 'config', array( + 'type' => 'blob', + 'size' => 'big', + 'not null' => FALSE, + 'description' => 'Configuration of the feeds object.', + 'serialize' => TRUE, + )); +} diff --git a/sites/all/modules/feeds/feeds.module b/sites/all/modules/feeds/feeds.module new file mode 100644 index 0000000000000000000000000000000000000000..0e1bda4a1158466882b8d888c6042733c75120fe --- /dev/null +++ b/sites/all/modules/feeds/feeds.module @@ -0,0 +1,1180 @@ +<?php + +/** + * @file + * Feeds - basic API functions and hook implementations. + */ + +// Common request time, use as point of reference and to avoid calls to time(). +define('FEEDS_REQUEST_TIME', time()); +// Do not schedule a feed for refresh. +define('FEEDS_SCHEDULE_NEVER', -1); +// Never expire feed items. +define('FEEDS_EXPIRE_NEVER', -1); +// An object that is not persistent. Compare EXPORT_IN_DATABASE, EXPORT_IN_CODE. +define('FEEDS_EXPORT_NONE', 0x0); +// Status of batched operations. +define('FEEDS_BATCH_COMPLETE', 1.0); +define('FEEDS_BATCH_ACTIVE', 0.0); + +/** + * @defgroup hooks Hook and callback implementations + * @{ + */ + +/** + * Implements hook_hook_info(). + */ +function feeds_hook_info() { + $hooks = array( + 'feeds_after_parse', + 'feeds_after_import', + 'feeds_after_clear', + 'feeds_processor_targets_alter', + 'feeds_parser_sources_alter', + ); + + return array_fill_keys($hooks, array('group' => 'feeds')); +} + +/** + * Implements hook_cron(). + */ +function feeds_cron() { + if ($importers = feeds_reschedule()) { + foreach ($importers as $id) { + feeds_importer($id)->schedule(); + $rows = db_query("SELECT feed_nid FROM {feeds_source} WHERE id = :id", array(':id' => $id)); + foreach ($rows as $row) { + feeds_source($id, $row->feed_nid)->schedule(); + } + } + feeds_reschedule(FALSE); + } + // Expire old log entries. + db_delete('feeds_log') + ->condition('request_time', REQUEST_TIME - 604800, '<') + ->execute(); +} + +/** + * Implements hook_cron_job_scheduler_info(). + * + * Compare queue names with key names in feeds_cron_queue_info(). + */ +function feeds_cron_job_scheduler_info() { + $info = array(); + $info['feeds_source_import'] = array( + 'queue name' => 'feeds_source_import', + ); + $info['feeds_source_clear'] = array( + 'queue name' => 'feeds_source_clear', + ); + $info['feeds_importer_expire'] = array( + 'queue name' => 'feeds_importer_expire', + ); + $info['feeds_push_unsubscribe'] = array( + 'queue name' => 'feeds_push_unsubscribe', + ); + return $info; +} + +/** + * Implements hook_cron_queue_info(). + */ +function feeds_cron_queue_info() { + $queues = array(); + $queues['feeds_source_import'] = array( + 'worker callback' => 'feeds_source_import', + 'time' => 15, + ); + $queues['feeds_source_clear'] = array( + 'worker callback' => 'feeds_source_clear', + 'time' => 15, + ); + $queues['feeds_importer_expire'] = array( + 'worker callback' => 'feeds_importer_expire', + 'time' => 15, + ); + $queues['feeds_push_unsubscribe'] = array( + 'worker callback' => 'feeds_push_unsubscribe', + 'time' => 15, + ); + return $queues; +} + +/** + * Scheduler callback for importing from a source. + */ +function feeds_source_import($job) { + $source = feeds_source($job['type'], $job['id']); + try { + $source->existing()->import(); + } + catch (FeedsNotExistingException $e) { + // Do nothing. + } + catch (Exception $e) { + $source->log('import', $e->getMessage(), array(), WATCHDOG_ERROR); + } + $source->scheduleImport(); +} + +/** + * Scheduler callback for deleting all items from a source. + */ +function feeds_source_clear($job) { + $source = feeds_source($job['type'], $job['id']); + try { + $source->existing()->clear(); + } + catch (FeedsNotExistingException $e) { + // Do nothing. + } + catch (Exception $e) { + $source->log('clear', $e->getMessage(), array(), WATCHDOG_ERROR); + } + $source->scheduleClear(); +} + +/** + * Scheduler callback for expiring content. + */ +function feeds_importer_expire($job) { + $importer = feeds_importer($job['type']); + try { + $importer->existing()->expire(); + } + catch (FeedsNotExistingException $e) { + // Do nothing. + } + catch (Exception $e) { + $importer->log('expire', $e->getMessage(), array(), WATCHDOG_ERROR); + } + $importer->scheduleExpire(); +} + +/** + * Scheduler callback for unsubscribing from PuSH hubs. + */ +function feeds_push_unsubscribe($job) { + $source = feeds_source($job['type'], $job['id']); + $fetcher = feeds_plugin('FeedsHTTPFetcher', $source->importer->id); + $fetcher->unsubscribe($source); +} + +/** + * Batch API worker callback. Used by FeedsSource::startBatchAPIJob(). + * + * @see FeedsSource::startBatchAPIJob(). + * + * @todo Harmonize Job Scheduler API callbacks with Batch API callbacks? + * + * @param $method + * Method to execute on importer; one of 'import' or 'clear'. + * @param $importer_id + * Identifier of a FeedsImporter object. + * @param $feed_nid + * If importer is attached to content type, feed node id identifying the + * source to be imported. + * @param $context + * Batch context. + */ +function feeds_batch($method, $importer_id, $feed_nid = 0, &$context) { + $context['finished'] = FEEDS_BATCH_COMPLETE; + try { + $context['finished'] = feeds_source($importer_id, $feed_nid)->$method(); + } + catch (Exception $e) { + drupal_set_message($e->getMessage(), 'error'); + } +} + +/** + * Reschedule one or all importers. + * + * @param $importer_id + * If TRUE, all importers will be rescheduled, if FALSE, no importers will + * be rescheduled, if an importer id, only importer of that id will be + * rescheduled. + * + * @return + * TRUE if all importers need rescheduling. FALSE if no rescheduling is + * required. An array of importers that need rescheduling. + */ +function feeds_reschedule($importer_id = NULL) { + $reschedule = variable_get('feeds_reschedule', FALSE); + if ($importer_id === TRUE || $importer_id === FALSE) { + $reschedule = $importer_id; + } + elseif (is_string($importer_id) && $reschedule !== TRUE) { + $reschedule = is_array($reschedule) ? $reschedule : array(); + $reschedule[$importer_id] = $importer_id; + } + variable_set('feeds_reschedule', $reschedule); + if ($reschedule === TRUE) { + return feeds_enabled_importers(); + } + return $reschedule; +} + +/** + * Implements feeds_permission(). + */ +function feeds_permission() { + $perms = array( + 'administer feeds' => array( + 'title' => t('Administer Feeds'), + 'description' => t('Create, update, delete importers, execute import and delete tasks on any importer.') + ), + ); + foreach (feeds_importer_load_all() as $importer) { + $perms["import $importer->id feeds"] = array( + 'title' => t('Import @name feeds', array('@name' => $importer->config['name'])), + ); + $perms["clear $importer->id feeds"] = array( + 'title' => t('Delete items from @name feeds', array('@name' => $importer->config['name'])), + ); + $perms["unlock $importer->id feeds"] = array( + 'title' => t('Unlock imports from @name feeds', array('@name' => $importer->config['name'])), + 'description' => t('If a feed importation breaks for some reason, users with this permission can unlock them.') + ); + } + return $perms; +} + +/** + * Implements hook_forms(). + * + * Declare form callbacks for all known classes derived from FeedsConfigurable. + */ +function feeds_forms() { + $forms = array(); + $forms['FeedsImporter_feeds_form']['callback'] = 'feeds_form'; + $plugins = FeedsPlugin::all(); + foreach ($plugins as $plugin) { + $forms[$plugin['handler']['class'] . '_feeds_form']['callback'] = 'feeds_form'; + } + return $forms; +} + +/** + * Implements hook_menu(). + */ +function feeds_menu() { + $items = array(); + $items['import'] = array( + 'title' => 'Import', + 'page callback' => 'feeds_page', + 'access callback' => 'feeds_page_access', + 'file' => 'feeds.pages.inc', + ); + $items['import/%'] = array( + 'title callback' => 'feeds_importer_title', + 'title arguments' => array(1), + 'page callback' => 'drupal_get_form', + 'page arguments' => array('feeds_import_form', 1), + 'access callback' => 'feeds_access', + 'access arguments' => array('import', 1), + 'file' => 'feeds.pages.inc', + ); + $items['import/%/import'] = array( + 'title' => 'Import', + 'type' => MENU_DEFAULT_LOCAL_TASK, + 'weight' => -10, + ); + $items['import/%/delete-items'] = array( + 'title' => 'Delete items', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('feeds_delete_tab_form', 1), + 'access callback' => 'feeds_access', + 'access arguments' => array('clear', 1), + 'file' => 'feeds.pages.inc', + 'type' => MENU_LOCAL_TASK, + ); + $items['import/%/unlock'] = array( + 'title' => 'Unlock', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('feeds_unlock_tab_form', 1), + 'access callback' => 'feeds_access', + 'access arguments' => array('unlock', 1), + 'file' => 'feeds.pages.inc', + 'type' => MENU_LOCAL_TASK, + ); + $items['import/%/template'] = array( + 'page callback' => 'feeds_importer_template', + 'page arguments' => array(1), + 'access callback' => 'feeds_access', + 'access arguments' => array('import', 1), + 'file' => 'feeds.pages.inc', + 'type' => MENU_CALLBACK, + ); + $items['node/%node/import'] = array( + 'title' => 'Import', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('feeds_import_tab_form', 1), + 'access callback' => 'feeds_access', + 'access arguments' => array('import', 1), + 'file' => 'feeds.pages.inc', + 'type' => MENU_LOCAL_TASK, + 'weight' => 10, + ); + $items['node/%node/delete-items'] = array( + 'title' => 'Delete items', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('feeds_delete_tab_form', NULL, 1), + 'access callback' => 'feeds_access', + 'access arguments' => array('clear', 1), + 'file' => 'feeds.pages.inc', + 'type' => MENU_LOCAL_TASK, + 'weight' => 11, + ); + $items['node/%node/unlock'] = array( + 'title' => 'Unlock', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('feeds_unlock_tab_form', NULL, 1), + 'access callback' => 'feeds_access', + 'access arguments' => array('unlock', 1), + 'file' => 'feeds.pages.inc', + 'type' => MENU_LOCAL_TASK, + 'weight' => 11, + ); + // @todo Eliminate this step and thus eliminate clearing menu cache when + // manipulating importers. + foreach (feeds_importer_load_all() as $importer) { + $items += $importer->fetcher->menuItem(); + } + return $items; +} + +/** + * Menu loader callback. + */ +function feeds_importer_load($id) { + return feeds_importer($id); +} + +/** + * Title callback. + */ +function feeds_importer_title($id) { + $importer = feeds_importer($id); + return $importer->config['name']; +} + +/** + * Implements hook_theme(). + */ +function feeds_theme() { + return array( + 'feeds_upload' => array( + 'file' => 'feeds.pages.inc', + 'render element' => 'element', + ), + 'feeds_source_status' => array( + 'file' => 'feeds.pages.inc', + 'variables' => array( + 'progress_importing' => NULL, + 'progress_clearing' => NULL, + 'imported' => NULL, + 'count' => NULL, + ), + ), + ); +} + +/** + * Menu access callback. + * + * @param $action + * The action to be performed. Possible values are: + * - import + * - clear + * - unlock + * @param $param + * Node object or FeedsImporter id. + */ +function feeds_access($action, $param) { + if (!in_array($action, array('import', 'clear', 'unlock'))) { + // If $action is not one of the supported actions, we return access denied. + return FALSE; + } + + if (is_string($param)) { + $importer_id = $param; + } + elseif ($param->type) { + $importer_id = feeds_get_importer_id($param->type); + } + + // Check for permissions if feed id is present, otherwise return FALSE. + if ($importer_id) { + if (user_access('administer feeds') || user_access("{$action} {$importer_id} feeds")) { + return TRUE; + } + } + return FALSE; +} + +/** + * Menu access callback. + */ +function feeds_page_access() { + if (user_access('administer feeds')) { + return TRUE; + } + foreach (feeds_enabled_importers() as $id) { + if (user_access("import $id feeds")) { + return TRUE; + } + } + return FALSE; +} + +/** + * Implements hook_exit(). + */ +function feeds_exit() { + // Process any pending PuSH subscriptions. + $jobs = feeds_get_subscription_jobs(); + foreach ($jobs as $job) { + if (!isset($job['fetcher']) || !isset($job['source'])) { + continue; + } + $job['fetcher']->subscribe($job['source']); + } + + if (drupal_static('feeds_log_error', FALSE)) { + watchdog('feeds', 'Feeds reported errors, visit the Feeds log for details.', array(), WATCHDOG_ERROR, 'admin/reports/dblog/feeds'); + } +} + +/** + * Implements hook_views_api(). + */ +function feeds_views_api() { + return array( + 'api' => 3, + 'path' => drupal_get_path('module', 'feeds') . '/views', + ); +} + +/** + * Implements hook_ctools_plugin_api(). + */ +function feeds_ctools_plugin_api($owner, $api) { + if ($owner == 'feeds' && $api == 'plugins') { + return array('version' => 1); + } +} + +/** + * Implements hook_ctools_plugin_type(). + */ +function feeds_ctools_plugin_type() { + return array( + 'plugins' => array( + 'cache' => TRUE, + 'use hooks' => TRUE, + 'classes' => array('handler'), + ), + ); +} + +/** + * Implements hook_feeds_plugins(). + */ +function feeds_feeds_plugins() { + module_load_include('inc', 'feeds', 'feeds.plugins'); + return _feeds_feeds_plugins(); +} + +/** + * Gets the feed_nid for a single entity. + * + * @param int $entity_id + * The entity id. + * @param string $entity_type + * The type of entity. + * + * @return int|bool + * The feed_nid of the entity, or FALSE if the entity doesn't belong to a + * feed. + */ +function feeds_get_feed_nid($entity_id, $entity_type) { + return db_query("SELECT feed_nid FROM {feeds_item} WHERE entity_type = :type AND entity_id = :id", array(':type' => $entity_type, ':id' => $entity_id))->fetchField(); +} + +/** + * Implements hook_entity_insert(). + */ +function feeds_entity_insert($entity, $type) { + list($id) = entity_extract_ids($type, $entity); + feeds_item_info_insert($entity, $id); +} + +/** + * Implements hook_entity_update(). + */ +function feeds_entity_update($entity, $type) { + list($id) = entity_extract_ids($type, $entity); + feeds_item_info_save($entity, $id); +} + +/** + * Implements hook_entity_delete(). + */ +function feeds_entity_delete($entity, $type) { + list($id) = entity_extract_ids($type, $entity); + + // Delete any imported items produced by the source. + db_delete('feeds_item') + ->condition('entity_type', $type) + ->condition('entity_id', $id) + ->execute(); +} + +/** + * Implements hook_node_validate(). + */ +function feeds_node_validate($node, $form, &$form_state) { + if (!$importer_id = feeds_get_importer_id($node->type)) { + return; + } + // Keep a copy of the title for subsequent node creation stages. + // @todo: revisit whether $node still looses all of its properties + // between validate and insert stage. + $last_title = &drupal_static('feeds_node_last_title'); + $last_feeds = &drupal_static('feeds_node_last_feeds'); + + // On validation stage we are working with a FeedsSource object that is + // not tied to a nid - when creating a new node there is no + // $node->nid at this stage. + $source = feeds_source($importer_id); + + // Node module magically moved $form['feeds'] to $node->feeds :P. + // configFormValidate may modify $last_feed, smuggle it to update/insert stage + // through a static variable. + $last_feeds = $node->feeds; + $source->configFormValidate($last_feeds); + + // If node title is empty, try to retrieve title from feed. + if (trim($node->title) == '') { + try { + $source->addConfig($last_feeds); + if (!$last_title = $source->preview()->title) { + throw new Exception(); + } + } + catch (Exception $e) { + drupal_set_message($e->getMessage(), 'error'); + form_set_error('title', t('Could not retrieve title from feed.')); + } + } +} + +/** + * Implements hook_node_presave(). + */ +function feeds_node_presave($node) { + // Populate $node->title and $node->feed from result of validation phase. + $last_title = &drupal_static('feeds_node_last_title'); + $last_feeds = &drupal_static('feeds_node_last_feeds'); + if (empty($node->title) && !empty($last_title)) { + $node->title = $last_title; + } + if (!empty($last_feeds)) { + $node->feeds = $last_feeds; + } + $last_title = NULL; + $last_feeds = NULL; +} + +/** + * Implements hook_node_insert(). + */ +function feeds_node_insert($node) { + // Source attached to node. + feeds_node_update($node); + if (isset($node->feeds) && $importer_id = feeds_get_importer_id($node->type)) { + $source = feeds_source($importer_id, $node->nid); + // Start import if requested. + if (feeds_importer($importer_id)->config['import_on_create'] && !isset($node->feeds['suppress_import'])) { + $source->startImport(); + } + // Schedule source and importer. + $source->schedule(); + feeds_importer($importer_id)->schedule(); + } +} + +/** + * Implements hook_node_update(). + */ +function feeds_node_update($node) { + // Source attached to node. + if (isset($node->feeds) && $importer_id = feeds_get_importer_id($node->type)) { + $source = feeds_source($importer_id, $node->nid); + $source->addConfig($node->feeds); + $source->save(); + } +} + +/** + * Implements hook_node_delete(). + */ +function feeds_node_delete($node) { + // Source attached to node. + // Make sure we don't leave any orphans behind: Do not use + // feeds_get_importer_id() to determine importer id as the importer may have + // been deleted. + if ($importer_id = db_query("SELECT id FROM {feeds_source} WHERE feed_nid = :nid", array(':nid' => $node->nid))->fetchField()) { + feeds_source($importer_id, $node->nid)->delete(); + } +} + +/** + * Implements hook_form_BASE_FORM_ID_alter(). + */ +function feeds_form_node_form_alter(&$form, $form_state) { + if ($importer_id = feeds_get_importer_id($form['#node']->type)) { + // Set title to not required, try to retrieve it from feed. + if (isset($form['title'])) { + $form['title']['#required'] = FALSE; + } + + // Enable uploads. + $form['#attributes']['enctype'] = 'multipart/form-data'; + + // Build form. + $source = feeds_source($importer_id, empty($form['#node']->nid) ? 0 : $form['#node']->nid); + $form['feeds'] = array( + '#type' => 'fieldset', + '#title' => t('Feed'), + '#tree' => TRUE, + '#weight' => 0, + ); + $form['feeds'] += $source->configForm($form_state); + $form['#feed_id'] = $importer_id; + } +} + +/** + * Implements hook_field_extra_fields(). + */ +function feeds_field_extra_fields() { + $extras = array(); + foreach (node_type_get_names() as $type => $name) { + if (feeds_get_importer_id($type)) { + $extras['node'][$type]['form']['feeds'] = array( + 'label' => t('Feed'), + 'description' => t('Feeds module form elements'), + 'weight' => 0, + ); + } + } + return $extras; +} + +/** + * @} + */ + +/** + * @defgroup utility Utility functions + * @{ + */ + +/** + * Loads all importers. + * + * @param $load_disabled + * Pass TRUE to load all importers, enabled or disabled, pass FALSE to only + * retrieve enabled importers. + * + * @return + * An array of all feed configurations available. + */ +function feeds_importer_load_all($load_disabled = FALSE) { + $feeds = array(); + // This function can get called very early in install process through + // menu_router_rebuild(). Do not try to include CTools if not available. + if (function_exists('ctools_include')) { + ctools_include('export'); + $configs = ctools_export_load_object('feeds_importer', 'all'); + foreach ($configs as $config) { + if (!empty($config->id) && ($load_disabled || empty($config->disabled))) { + $feeds[$config->id] = feeds_importer($config->id); + } + } + } + return $feeds; +} + +/** + * Gets an array of enabled importer ids. + * + * @return + * An array where the values contain ids of enabled importers. + */ +function feeds_enabled_importers() { + return array_keys(_feeds_importer_digest()); +} + +/** + * Gets an enabled importer configuration by content type. + * + * @param $content_type + * A node type string. + * + * @return + * A FeedsImporter id if there is an importer for the given content type, + * FALSE otherwise. + */ +function feeds_get_importer_id($content_type) { + $importers = array_flip(_feeds_importer_digest()); + return isset($importers[$content_type]) ? $importers[$content_type] : FALSE; +} + +/** + * Helper function for feeds_get_importer_id() and feeds_enabled_importers(). + */ +function _feeds_importer_digest() { + $importers = &drupal_static(__FUNCTION__); + if ($importers === NULL) { + if ($cache = cache_get(__FUNCTION__)) { + $importers = $cache->data; + } + else { + $importers = array(); + foreach (feeds_importer_load_all() as $importer) { + $importers[$importer->id] = isset($importer->config['content_type']) ? $importer->config['content_type'] : ''; + } + cache_set(__FUNCTION__, $importers); + } + } + return $importers; +} + +/** + * Resets importer caches. Call when enabling/disabling importers. + */ +function feeds_cache_clear($rebuild_menu = TRUE) { + cache_clear_all('_feeds_importer_digest', 'cache'); + drupal_static_reset('_feeds_importer_digest'); + ctools_include('export'); + ctools_export_load_object_reset('feeds_importer'); + drupal_static_reset('_node_types_build'); + if ($rebuild_menu) { + menu_rebuild(); + } +} + +/** + * Exports a FeedsImporter configuration to code. + */ +function feeds_export($importer_id, $indent = '') { + ctools_include('export'); + $result = ctools_export_load_object('feeds_importer', 'names', array('id' => $importer_id)); + if (isset($result[$importer_id])) { + return ctools_export_object('feeds_importer', $result[$importer_id], $indent); + } +} + +/** + * Logs to a file like /tmp/feeds_my_domain_org.log in temporary directory. + */ +function feeds_dbg($msg) { + if (variable_get('feeds_debug', FALSE)) { + if (!is_string($msg)) { + $msg = var_export($msg, TRUE); + } + $filename = trim(str_replace('/', '_', $_SERVER['HTTP_HOST'] . base_path()), '_'); + $handle = fopen("temporary://feeds_$filename.log", 'a'); + fwrite($handle, gmdate('c') . "\t$msg\n"); + fclose($handle); + } +} + +/** + * Writes to feeds log. + */ +function feeds_log($importer_id, $feed_nid, $type, $message, $variables = array(), $severity = WATCHDOG_NOTICE) { + if ($severity < WATCHDOG_NOTICE) { + $error = &drupal_static('feeds_log_error', FALSE); + $error = TRUE; + } + db_insert('feeds_log') + ->fields(array( + 'id' => $importer_id, + 'feed_nid' => $feed_nid, + 'log_time' => time(), + 'request_time' => REQUEST_TIME, + 'type' => $type, + 'message' => $message, + 'variables' => serialize($variables), + 'severity' => $severity, + )) + ->execute(); +} + +/** + * Loads an item info object. + * + * Example usage: + * + * $info = feeds_item_info_load('node', $node->nid); + */ +function feeds_item_info_load($entity_type, $entity_id) { + return db_select('feeds_item') + ->fields('feeds_item') + ->condition('entity_type', $entity_type) + ->condition('entity_id', $entity_id) + ->execute() + ->fetchObject(); +} + +/** + * Inserts an item info object into the feeds_item table. + */ +function feeds_item_info_insert($entity, $entity_id) { + if (isset($entity->feeds_item)) { + $entity->feeds_item->entity_id = $entity_id; + drupal_write_record('feeds_item', $entity->feeds_item); + } +} + +/** + * Inserts or updates an item info object in the feeds_item table. + */ +function feeds_item_info_save($entity, $entity_id) { + if (isset($entity->feeds_item)) { + $entity->feeds_item->entity_id = $entity_id; + if (feeds_item_info_load($entity->feeds_item->entity_type, $entity_id)) { + drupal_write_record('feeds_item', $entity->feeds_item, array('entity_type', 'entity_id')); + } + else { + feeds_item_info_insert($entity, $entity_id); + } + } +} + +/** + * @} + */ + +/** + * @defgroup instantiators Instantiators + * @{ + */ + +/** + * Gets an importer instance. + * + * @param $id + * The unique id of the importer object. + * + * @return + * A FeedsImporter object or an object of a class defined by the Drupal + * variable 'feeds_importer_class'. There is only one importer object + * per $id system-wide. + */ +function feeds_importer($id) { + return FeedsConfigurable::instance(variable_get('feeds_importer_class', 'FeedsImporter'), $id); +} + +/** + * Gets an instance of a source object. + * + * @param $importer_id + * A FeedsImporter id. + * @param $feed_nid + * The node id of a feed node if the source is attached to a feed node. + * + * @return + * A FeedsSource object or an object of a class defiend by the Drupal + * variable 'source_class'. + */ +function feeds_source($importer_id, $feed_nid = 0) { + return FeedsSource::instance($importer_id, $feed_nid); +} + +/** + * Gets an instance of a class for a given plugin and id. + * + * @param $plugin + * A string that is the key of the plugin to load. + * @param $id + * A string that is the id of the object. + * + * @return + * A FeedsPlugin object. + * + * @throws Exception + * If plugin can't be instantiated. + */ +function feeds_plugin($plugin, $id) { + ctools_include('plugins'); + if ($class = ctools_plugin_load_class('feeds', 'plugins', $plugin, 'handler')) { + return FeedsConfigurable::instance($class, $id); + } + $args = array('%plugin' => $plugin, '@id' => $id); + if (user_access('administer feeds')) { + $args['@link'] = url('admin/structure/feeds/' . $id); + drupal_set_message(t('Missing Feeds plugin %plugin. See <a href="@link">@id</a>. Check whether all required libraries and modules are installed properly.', $args), 'warning', FALSE); + } + else { + drupal_set_message(t('Missing Feeds plugin %plugin. Please contact your site administrator.', $args), 'warning', FALSE); + } + $class = ctools_plugin_load_class('feeds', 'plugins', 'FeedsMissingPlugin', 'handler'); + return FeedsConfigurable::instance($class, $id); +} + +/** + * @} + */ + +/** + * @defgroup include Funtions for loading libraries + * @{ + */ + +/** + * Includes a library file. + * + * @param $file + * The filename to load from. + * @param $library + * The name of the library. If libraries module is installed, + * feeds_include_library() will look for libraries with this name managed by + * libraries module. + */ +function feeds_include_library($file, $library) { + static $included = array(); + static $ignore_deprecated = array('simplepie'); + + if (!isset($included[$file])) { + // Disable deprecated warning for libraries known for throwing them + if (in_array($library, $ignore_deprecated)) { + $level = error_reporting(); + // We can safely use E_DEPRECATED since Drupal 7 requires PHP 5.3+ + error_reporting($level ^ E_DEPRECATED ^ E_STRICT); + } + + $library_dir = variable_get('feeds_library_dir', FALSE); + $feeds_library_path = DRUPAL_ROOT . '/' . drupal_get_path('module', 'feeds') . "/libraries/$file"; + + // Try first whether libraries module is present and load the file from + // there. If this fails, require the library from the local path. + if (module_exists('libraries') && file_exists(libraries_get_path($library) . "/$file")) { + require libraries_get_path($library) . "/$file"; + $included[$file] = TRUE; + } + elseif ($library_dir && file_exists("$library_dir/$library/$file")) { + require "$library_dir/$library/$file"; + $included[$file] = TRUE; + } + elseif (file_exists($feeds_library_path)) { + // @todo: Throws "Deprecated function: Assigning the return value of new + // by reference is deprecated." + require $feeds_library_path; + $included[$file] = TRUE; + } + // Restore error reporting level + if (isset($level)) { + error_reporting($level); + } + } + if (isset($included[$file])) { + return TRUE; + } + return FALSE; +} + +/** + * Checks whether a library is present. + * + * @param $file + * The filename to load from. + * @param $library + * The name of the library. If libraries module is installed, + * feeds_library_exists() will look for libraries with this name managed by + * libraries module. + */ +function feeds_library_exists($file, $library) { + + if (module_exists('libraries') && file_exists(libraries_get_path($library) . "/$file")) { + return TRUE; + } + + elseif (file_exists(DRUPAL_ROOT . '/' . drupal_get_path('module', 'feeds') . "/libraries/$file")) { + return TRUE; + } + + elseif ($library_dir = variable_get('feeds_library_dir', FALSE)) { + if (file_exists("$library_dir/$library/$file")) { + return TRUE; + } + } + + return FALSE; +} + + /** + * Checks whether simplepie exists. + */ +function feeds_simplepie_exists() { + return (feeds_library_exists('simplepie.compiled.php', 'simplepie') || + feeds_library_exists('simplepie.mini.php', 'simplepie') || + feeds_library_exists('simplepie.inc', 'simplepie') + ); +} + +/** + * Includes the simplepie library. + */ +function feeds_include_simplepie() { + $files = array('simplepie.mini.php', 'simplepie.compiled.php', 'simplepie.inc'); + + foreach ($files as $file) { + if (feeds_include_library($file, 'simplepie')) { + return TRUE; + } + } + return FALSE; +} + +/** + * @deprecated + * + * Simplified drupal_alter(). + * + * - None of that 'multiple parameters by ref' crazyness. + * - Don't use module_implements() to allow hot including on behalf + * implementations (see mappers/). + * + * @todo This needs to be removed and drupal_alter() used. This is crazy dumb. + */ +function feeds_alter($type, &$data) { + $args = array(&$data); + $additional_args = func_get_args(); + array_shift($additional_args); + array_shift($additional_args); + $args = array_merge($args, $additional_args); + + $hook = $type . '_alter'; + foreach (module_list() as $module) { + if (module_hook($module, $hook)) { + call_user_func_array($module . '_' . $hook, $args); + } + } +} + +/** + * @} + */ + +/** + * Copy of valid_url() that supports the webcal scheme. + * + * @see valid_url(). + * + * @todo Replace with valid_url() when http://drupal.org/node/295021 is fixed. + */ +function feeds_valid_url($url, $absolute = FALSE) { + if ($absolute) { + return (bool) preg_match(" + /^ # Start at the beginning of the text + (?:ftp|https?|feed|webcal):\/\/ # Look for ftp, http, https, feed or webcal schemes + (?: # Userinfo (optional) which is typically + (?:(?:[\w\.\-\+!$&'\(\)*\+,;=]|%[0-9a-f]{2})+:)* # a username or a username and password + (?:[\w\.\-\+%!$&'\(\)*\+,;=]|%[0-9a-f]{2})+@ # combination + )? + (?: + (?:[a-z0-9\-\.]|%[0-9a-f]{2})+ # A domain name or a IPv4 address + |(?:\[(?:[0-9a-f]{0,4}:)*(?:[0-9a-f]{0,4})\]) # or a well formed IPv6 address + ) + (?::[0-9]+)? # Server port number (optional) + (?:[\/|\?] + (?:[|\w#!:\.\?\+=&@$'~*,;\/\(\)\[\]\-]|%[0-9a-f]{2}) # The path and query (optional) + *)? + $/xi", $url); + } + else { + return (bool) preg_match("/^(?:[\w#!:\.\?\+=&@$'~*,;\/\(\)\[\]\-]|%[0-9a-f]{2})+$/i", $url); + } +} + +/** + * Registers a feed subscription job for execution on feeds_exit(). + * + * @param array $job + * Information about a new job to queue; or if set to NULL (default), leaves + * the current queued jobs unchanged. + * + * @return + * An array of subscribe jobs to process. + * + * @see feeds_exit() + * @see feeds_get_subscription_jobs() + */ +function feeds_set_subscription_job(array $job = NULL) { + $jobs = &drupal_static(__FUNCTION__, array()); + if (isset($job)) { + $jobs[] = $job; + } + return $jobs; +} + +/** + * Returns the list of queued jobs to be run. + * + * @return + * An array of subscribe jobs to process. + * + * @see feeds_set_subscription_job() + */ +function feeds_get_subscription_jobs() { + return feeds_set_subscription_job(); +} + +/** + * Implements hook_entity_property_info_alter(). + */ +function feeds_entity_property_info_alter(&$info) { + // Gather entities supported by Feeds processors. + $processors = FeedsPlugin::byType('processor'); + $supported_entities = array(); + foreach ($processors as $processor) { + $instance = feeds_plugin($processor['handler']['class'], '__none__'); + if (method_exists($instance, 'entityType')) { + $supported_entities[] = $instance->entityType(); + } + } + // Feeds processors can fake the entity info. Only set the property for + // defined entities. + $supported_entities = array_intersect(array_keys($info), $supported_entities); + + foreach ($supported_entities as $entity_type) { + $info[$entity_type]['properties']['feed_nid'] = array( + 'label' => 'Feed NID', + 'type' => 'integer', + 'description' => t('Nid of the Feed Node that imported this entity.'), + 'getter callback' => 'feeds_get_feed_nid_entity_callback', + ); + } +} + +/** + * Gets the feed_nid for an entity for use in entity metadata. + */ +function feeds_get_feed_nid_entity_callback($entity, array $options, $name, $entity_type) { + list($entity_id, , ) = entity_extract_ids($entity_type, $entity); + + $feed_nid = feeds_get_feed_nid($entity_id, $entity_type); + + if ($feed_nid === FALSE) { + return NULL; + } + return $feed_nid; +} diff --git a/sites/all/modules/feeds/feeds.pages.inc b/sites/all/modules/feeds/feeds.pages.inc new file mode 100644 index 0000000000000000000000000000000000000000..6f1481edf6d44cd9230e8b3e6a1f11fbe8ab4f5a --- /dev/null +++ b/sites/all/modules/feeds/feeds.pages.inc @@ -0,0 +1,362 @@ +<?php + +/** + * @file + * Menu callbacks, form callbacks and helpers. + */ + +/** + * Render a page of available importers. + */ +function feeds_page() { + $rows = array(); + if ($importers = feeds_importer_load_all()) { + foreach ($importers as $importer) { + if ($importer->disabled) { + continue; + } + if (!(user_access('import ' . $importer->id . ' feeds') || user_access('administer feeds'))) { + continue; + } + if (empty($importer->config['content_type'])) { + $link = 'import/' . $importer->id; + $title = $importer->config['name']; + } + elseif (node_access('create', $importer->config['content_type'])) { + $link = 'node/add/' . str_replace('_', '-', $importer->config['content_type']); + $title = node_type_get_name($importer->config['content_type']); + } + else { + continue; + } + $rows[] = array( + l($title, $link), + check_plain($importer->config['description']), + ); + } + } + if (empty($rows)) { + drupal_set_message(t('There are no importers, go to <a href="@importers">Feed importers</a> to create one or enable an existing one.', array('@importers' => url('admin/structure/feeds')))); + } + $header = array( + t('Import'), + t('Description'), + ); + return theme('table', array('header' => $header, 'rows' => $rows)); +} + +/** + * Render a feeds import form on import/[config] pages. + */ +function feeds_import_form($form, &$form_state, $importer_id) { + $source = feeds_source($importer_id, empty($form['nid']['#value']) ? 0 : $form['nid']['#value']); + + $form = array(); + $form['#importer_id'] = $importer_id; + // @todo Move this into fetcher? + $form['#attributes']['enctype'] = 'multipart/form-data'; + $form['source_status'] = array( + '#type' => 'fieldset', + '#title' => t('Status'), + '#tree' => TRUE, + '#value' => feeds_source_status($source), + ); + + $source_form = $source->configForm($form_state); + if (!empty($source_form)) { + $form['feeds'] = array( + '#type' => 'fieldset', + '#title' => t('Import'), + '#tree' => TRUE, + ) + $source_form; + } + + $form['submit'] = array( + '#type' => 'submit', + '#value' => t('Import'), + ); + $progress = $source->progressImporting(); + if ($progress !== FEEDS_BATCH_COMPLETE) { + $form['submit']['#disabled'] = TRUE; + $form['submit']['#value'] = + t('Importing (@progress %)', array('@progress' => number_format(100 * $progress, 0))); + } + return $form; +} + +/** + * Validation handler for node forms and feeds_import_form(). + */ +function feeds_import_form_validate($form, &$form_state) { + // @todo This may be a problem here, as we don't have a feed_nid at this point. + feeds_source($form['#importer_id'])->configFormValidate($form_state['values']['feeds']); +} + +/** + * Submit handler for feeds_import_form(). + */ +function feeds_import_form_submit($form, &$form_state) { + // Save source and import. + $source = feeds_source($form['#importer_id']); + + if (!empty($form_state['values']['feeds']) && is_array($form_state['values']['feeds'])) { + $source->addConfig($form_state['values']['feeds']); + $source->save(); + } + + // Refresh feed if import on create is selected. + if ($source->importer->config['import_on_create']) { + $source->startImport(); + } + + // Add to schedule, make sure importer is scheduled, too. + $source->schedule(); + $source->importer->schedule(); +} + +/** + * Render a feeds import form on node/id/import pages. + */ +function feeds_import_tab_form($form, &$form_state, $node) { + $importer_id = feeds_get_importer_id($node->type); + $source = feeds_source($importer_id, $node->nid); + + $form = array(); + $form['#feed_nid'] = $node->nid; + $form['#importer_id'] = $importer_id; + $form['#redirect'] = 'node/' . $node->nid; + $form['source_status'] = array( + '#type' => 'fieldset', + '#title' => t('Status'), + '#tree' => TRUE, + '#value' => feeds_source_status($source), + ); + $form = confirm_form($form, t('Import all content from source?'), 'node/' . $node->nid, '', t('Import'), t('Cancel'), 'confirm feeds update'); + $progress = $source->progressImporting(); + if ($progress !== FEEDS_BATCH_COMPLETE) { + $form['actions']['submit']['#disabled'] = TRUE; + $form['actions']['submit']['#value'] = + t('Importing (@progress %)', array('@progress' => number_format(100 * $progress, 0))); + } + return $form; +} + +/** + * Submit handler for feeds_import_tab_form(). + */ +function feeds_import_tab_form_submit($form, &$form_state) { + $form_state['redirect'] = $form['#redirect']; + feeds_source($form['#importer_id'], $form['#feed_nid'])->startImport(); +} + +/** + * Render a feeds delete form. + * + * Used on both node pages and configuration pages. + * Therefore $node may be missing. + */ +function feeds_delete_tab_form($form, &$form_state, $importer_id, $node = NULL) { + if (empty($node)) { + $source = feeds_source($importer_id); + $form['#redirect'] = 'import/' . $source->id; + } + else { + $importer_id = feeds_get_importer_id($node->type); + $source = feeds_source($importer_id, $node->nid); + $form['#redirect'] = 'node/' . $source->feed_nid; + } + // Form cannot pass on source object. + $form['#importer_id'] = $source->id; + $form['#feed_nid'] = $source->feed_nid; + $form['source_status'] = array( + '#type' => 'fieldset', + '#title' => t('Status'), + '#tree' => TRUE, + '#value' => feeds_source_status($source), + ); + $form = confirm_form($form, t('Delete all items from source?'), $form['#redirect'], '', t('Delete'), t('Cancel'), 'confirm feeds update'); + $progress = $source->progressClearing(); + if ($progress !== FEEDS_BATCH_COMPLETE) { + $form['actions']['submit']['#disabled'] = TRUE; + $form['actions']['submit']['#value'] = + t('Deleting (@progress %)', array('@progress' => number_format(100 * $progress, 0))); + } + return $form; +} + +/** + * Submit handler for feeds_delete_tab_form(). + */ +function feeds_delete_tab_form_submit($form, &$form_state) { + $form_state['redirect'] = $form['#redirect']; + $feed_nid = empty($form['#feed_nid']) ? 0 : $form['#feed_nid']; + feeds_source($form['#importer_id'], $feed_nid)->startClear(); +} + +/** + * Render a feeds unlock form. + * + * Used on both node pages and configuration pages. + * Therefore $node may be missing. + */ +function feeds_unlock_tab_form($form, &$form_state, $importer_id, $node = NULL) { + if (empty($node)) { + $source = feeds_source($importer_id); + $form['#redirect'] = 'import/' . $source->id; + } + else { + $importer_id = feeds_get_importer_id($node->type); + $source = feeds_source($importer_id, $node->nid); + $form['#redirect'] = 'node/' . $source->feed_nid; + } + // Form cannot pass on source object. + $form['#importer_id'] = $source->id; + $form['#feed_nid'] = $source->feed_nid; + $form['source_status'] = array( + '#type' => 'fieldset', + '#title' => t('Status'), + '#tree' => TRUE, + '#value' => feeds_source_status($source), + ); + $form = confirm_form($form, t('Unlock this importer?'), $form['#redirect'], '', t('Delete'), t('Cancel'), 'confirm feeds update'); + if ($source->progressImporting() == FEEDS_BATCH_COMPLETE && $source->progressClearing() == FEEDS_BATCH_COMPLETE) { + $form['source_locked'] = array( + '#type' => 'markup', + '#title' => t('Not Locked'), + '#tree' => TRUE, + '#markup' => t('This importer is not locked, therefore it cannot be unlocked.'), + ); + $form['actions']['submit']['#disabled'] = TRUE; + $form['actions']['submit']['#value'] = t('Unlock (disabled)'); + } + else { + $form['actions']['submit']['#value'] = t('Unlock'); + } + return $form; +} + +/** + * Form submit handler. Resets all feeds state. + */ +function feeds_unlock_tab_form_submit($form, &$form_state) { + drupal_set_message(t('Import Unlocked')); + $form_state['redirect'] = $form['#redirect']; + $feed_nid = empty($form['#feed_nid']) ? 0 : $form['#feed_nid']; + $importer_id = $form['#importer_id']; + + //Is there a more API-friendly way to set the state? + db_update('feeds_source') + ->condition('id', $importer_id) + ->condition('feed_nid', $feed_nid) + ->fields(array('state' => FALSE)) + ->execute(); +} + +/** + * Handle a fetcher callback. + */ +function feeds_fetcher_callback($importer, $feed_nid = 0) { + if ($importer instanceof FeedsImporter) { + try { + return $importer->fetcher->request($feed_nid); + } + catch (Exception $e) { + // Do nothing. + } + } + drupal_access_denied(); +} + +/** + * Template generation + */ +function feeds_importer_template($importer_id) { + $importer = feeds_importer($importer_id); + if ($importer->parser instanceof FeedsCSVParser) { + return $importer->parser->getTemplate(); + } + return drupal_not_found(); +} + +/** + * Renders a status display for a source. + */ +function feeds_source_status($source) { + $progress_importing = $source->progressImporting(); + $v = array(); + if ($progress_importing != FEEDS_BATCH_COMPLETE) { + $v['progress_importing'] = $progress_importing; + } + $progress_clearing = $source->progressClearing(); + if ($progress_clearing != FEEDS_BATCH_COMPLETE) { + $v['progress_clearing'] = $progress_clearing; + } + $v['imported'] = $source->imported; + $v['count'] = $source->itemCount(); + if (!empty($v)) { + return theme('feeds_source_status', $v); + } +} + +/** + * Themes a status display for a source. + */ +function theme_feeds_source_status($v) { + $output = '<div class="info-box feeds-source-status">'; + $items = array(); + if ($v['progress_importing']) { + $progress = number_format(100.0 * $v['progress_importing'], 0); + $items[] = t('Importing - @progress % complete.', array('@progress' => $progress)); + } + if ($v['progress_clearing']) { + $progress = number_format(100.0 * $v['progress_clearing'], 0); + $items[] = t('Deleting items - @progress % complete.', array('@progress' => $progress)); + } + if (!count($items)) { + if ($v['count']) { + if ($v['imported']) { + $items[] = t('Last import: @ago ago.', array('@ago' => format_interval(REQUEST_TIME - $v['imported'], 1))); + } + $items[] = t('@count imported items total.', array('@count' => $v['count'])); + } + else { + $items[] = t('No imported items.'); + } + } + $output .= theme('item_list', array('items' => $items)); + $output .= '</div>'; + return $output; +} + +/** + * Theme upload widget. + */ +function theme_feeds_upload($variables) { + $element = $variables['element']; + drupal_add_css(drupal_get_path('module', 'feeds') . '/feeds.css'); + _form_set_class($element, array('form-file')); + $description = ''; + if (!empty($element['#file_info'])) { + $file = $element['#file_info']; + $wrapper = file_stream_wrapper_get_instance_by_uri($file->uri); + $description .= '<div class="file-info">'; + $description .= '<div class="file-name">'; + $description .= l($file->filename, $wrapper->getExternalUrl()); + $description .= '</div>'; + $description .= '<div class="file-size">'; + $description .= format_size($file->filesize); + $description .= '</div>'; + $description .= '<div class="file-mime">'; + $description .= check_plain($file->filemime); + $description .= '</div>'; + $description .= '</div>'; + } + $description .= '<div class="file-upload">'; + $description .= '<input type="file" name="' . $element['#name'] . '"' . ($element['#attributes'] ? ' ' . drupal_attributes($element['#attributes']) : '') . ' id="' . $element['#id'] . '" size="' . $element['#size'] . "\" />\n"; + $description .= '</div>'; + $element['#description'] = $description; + + // For some reason not unsetting #title leads to printing the title twice. + unset($element['#title']); + return theme('form_element', $element); +} diff --git a/sites/all/modules/feeds/feeds.plugins.inc b/sites/all/modules/feeds/feeds.plugins.inc new file mode 100644 index 0000000000000000000000000000000000000000..3f23cb817f913cb62a4c49ab280cede840451b8f --- /dev/null +++ b/sites/all/modules/feeds/feeds.plugins.inc @@ -0,0 +1,168 @@ +<?php + +/** + * @file + * CTools plugins declarations. + */ + +/** + * Break out for feeds_feed_plugins(). + */ +function _feeds_feeds_plugins() { + $path = drupal_get_path('module', 'feeds') . '/plugins'; + + $info = array(); + $info['FeedsPlugin'] = array( + 'hidden' => TRUE, + 'handler' => array( + 'class' => 'FeedsPlugin', + 'file' => 'FeedsPlugin.inc', + 'path' => $path, + ), + ); + $info['FeedsMissingPlugin'] = array( + 'hidden' => TRUE, + 'handler' => array( + 'class' => 'FeedsMissingPlugin', + 'file' => 'FeedsPlugin.inc', + 'path' => $path, + ), + ); + $info['FeedsFetcher'] = array( + 'hidden' => TRUE, + 'handler' => array( + 'parent' => 'FeedsPlugin', + 'class' => 'FeedsFetcher', + 'file' => 'FeedsFetcher.inc', + 'path' => $path, + ), + ); + $info['FeedsParser'] = array( + 'hidden' => TRUE, + 'handler' => array( + 'parent' => 'FeedsPlugin', + 'class' => 'FeedsParser', + 'file' => 'FeedsParser.inc', + 'path' => $path, + ), + ); + $info['FeedsProcessor'] = array( + 'hidden' => TRUE, + 'handler' => array( + 'parent' => 'FeedsPlugin', + 'class' => 'FeedsProcessor', + 'file' => 'FeedsProcessor.inc', + 'path' => $path, + ), + ); + $info['FeedsHTTPFetcher'] = array( + 'name' => 'HTTP Fetcher', + 'description' => 'Download content from a URL.', + 'handler' => array( + 'parent' => 'FeedsFetcher', // This is the key name, not the class name. + 'class' => 'FeedsHTTPFetcher', + 'file' => 'FeedsHTTPFetcher.inc', + 'path' => $path, + ), + ); + $info['FeedsFileFetcher'] = array( + 'name' => 'File upload', + 'description' => 'Upload content from a local file.', + 'handler' => array( + 'parent' => 'FeedsFetcher', + 'class' => 'FeedsFileFetcher', + 'file' => 'FeedsFileFetcher.inc', + 'path' => $path, + ), + ); + $info['FeedsCSVParser'] = array( + 'name' => 'CSV parser', + 'description' => 'Parse data in Comma Separated Value format.', + 'handler' => array( + 'parent' => 'FeedsParser', + 'class' => 'FeedsCSVParser', + 'file' => 'FeedsCSVParser.inc', + 'path' => $path, + ), + ); + $info['FeedsSyndicationParser'] = array( + 'name' => 'Common syndication parser', + 'description' => 'Parse RSS and Atom feeds.', + 'help' => 'Parse XML feeds in RSS 1, RSS 2 and Atom format.', + 'handler' => array( + 'parent' => 'FeedsParser', + 'class' => 'FeedsSyndicationParser', + 'file' => 'FeedsSyndicationParser.inc', + 'path' => $path, + ), + ); + $info['FeedsOPMLParser'] = array( + 'name' => 'OPML parser', + 'description' => 'Parse OPML files.', + 'handler' => array( + 'parent' => 'FeedsParser', + 'class' => 'FeedsOPMLParser', + 'file' => 'FeedsOPMLParser.inc', + 'path' => $path, + ), + ); + if (feeds_simplepie_exists()) { + $info['FeedsSimplePieParser'] = array( + 'name' => 'SimplePie parser', + 'description' => 'Parse RSS and Atom feeds.', + 'help' => 'Use <a href="http://simplepie.org">SimplePie</a> to parse XML feeds in RSS 1, RSS 2 and Atom format.', + 'handler' => array( + 'parent' => 'FeedsParser', + 'class' => 'FeedsSimplePieParser', + 'file' => 'FeedsSimplePieParser.inc', + 'path' => $path, + ), + ); + } + $info['FeedsSitemapParser'] = array( + 'name' => 'Sitemap parser', + 'description' => 'Parse Sitemap XML format feeds.', + 'handler' => array( + 'parent' => 'FeedsParser', + 'class' => 'FeedsSitemapParser', + 'file' => 'FeedsSitemapParser.inc', + 'path' => $path, + ), + ); + $info['FeedsNodeProcessor'] = array( + 'name' => 'Node processor', + 'description' => 'Create and update nodes.', + 'help' => 'Create and update nodes from parsed content.', + 'handler' => array( + 'parent' => 'FeedsProcessor', + 'class' => 'FeedsNodeProcessor', + 'file' => 'FeedsNodeProcessor.inc', + 'path' => $path, + ), + ); + $info['FeedsUserProcessor'] = array( + 'name' => 'User processor', + 'description' => 'Create users.', + 'help' => 'Create users from parsed content.', + 'handler' => array( + 'parent' => 'FeedsProcessor', + 'class' => 'FeedsUserProcessor', + 'file' => 'FeedsUserProcessor.inc', + 'path' => $path, + ), + ); + if (module_exists('taxonomy')) { + $info['FeedsTermProcessor'] = array( + 'name' => 'Taxonomy term processor', + 'description' => 'Create taxonomy terms.', + 'help' => 'Create taxonomy terms from parsed content.', + 'handler' => array( + 'parent' => 'FeedsProcessor', + 'class' => 'FeedsTermProcessor', + 'file' => 'FeedsTermProcessor.inc', + 'path' => $path, + ), + ); + } + return $info; +} diff --git a/sites/all/modules/feeds/feeds.rules.inc b/sites/all/modules/feeds/feeds.rules.inc new file mode 100644 index 0000000000000000000000000000000000000000..84b4685e22c0cecd8d0103b431b3a221c48fd28f --- /dev/null +++ b/sites/all/modules/feeds/feeds.rules.inc @@ -0,0 +1,89 @@ +<?php + +/** + * @file + * Rules integration. + */ + +/** + * Implements hook_rules_event_info(). + */ +function feeds_rules_event_info() { + $info = array(); + $entity_info = entity_get_info(); + + foreach (feeds_importer_load_all() as $importer) { + $config = $importer->getConfig(); + $processor = feeds_plugin($config['processor']['plugin_key'], $importer->id); + + // It's possible to get FeedsMissingPlugin here which will break things + // since it doesn't implement FeedsProcessor::entityType(). + if (!$processor instanceof FeedsProcessor) { + continue; + } + + $entity_type = $processor->entityType(); + $label = isset($entity_info[$entity_type]['label']) ? $entity_info[$entity_type]['label'] : $entity_type; + + $info['feeds_import_'. $importer->id] = array( + 'label' => t('Before saving an item imported via @name.', array('@name' => $importer->config['name'])), + 'group' => t('Feeds'), + 'variables' => array( + $entity_type => array( + 'label' => t('Imported @label', array('@label' => $label)), + 'type' => $entity_type, + // Saving is handled by feeds anyway (unless the skip action is used). + 'skip save' => TRUE, + ), + ), + 'access callback' => 'feeds_rules_access_callback', + ); + // Add bundle information if the node processor is used. + if ($processor instanceof FeedsNodeProcessor) { + $config = $processor->getConfig(); + $info['feeds_import_'. $importer->id]['variables'][$entity_type]['bundle'] = $config['content_type']; + } + } + return $info; +} + +/** + * Implements of hook_rules_action_info(). + */ +function feeds_rules_action_info() { + return array( + 'feeds_skip_item' => array( + 'base' => 'feeds_action_skip_item', + 'label' => t('Skip import of feeds item'), + 'group' => t('Feeds'), + 'parameter' => array( + 'entity' => array('type' => 'entity', 'label' => t('The feeds import item to be marked as skipped')), + ), + 'access callback' => 'feeds_rules_access_callback', + ), + ); +} + +/** + * Mark feeds import item as skipped. + */ +function feeds_action_skip_item($entity_wrapper) { + $entity = $entity_wrapper->value(); + if (isset($entity->feeds_item)) { + $entity->feeds_item->skip = TRUE; + } +} + +/** + * Help callback for the skip action. + */ +function feeds_action_skip_item_help() { + return t("This action allows skipping certain feed items during feeds processing, i.e. before an imported item is saved. Once this action is used on a item, the changes to the entity of the feed item are not saved."); +} + +/** + * Access callback for the feeds rules integration. + */ +function feeds_rules_access_callback() { + return user_access('administer feeds'); +} diff --git a/sites/all/modules/feeds/feeds.tokens.inc b/sites/all/modules/feeds/feeds.tokens.inc new file mode 100644 index 0000000000000000000000000000000000000000..cb7dc4c7da64113b6171c34d94b1f5cdedc834ee --- /dev/null +++ b/sites/all/modules/feeds/feeds.tokens.inc @@ -0,0 +1,51 @@ +<?php + +/** + * @file + * Builds placeholder replacement tokens for feed-related data. + */ + +/** + * Implements hook_token_info(). + */ +function feeds_token_info() { + // @todo This token could be for any entity type. + $info['tokens']['node']['feed-source'] = array( + 'name' => t('Feed source'), + 'description' => t('The node the feed item was sourced from.'), + 'type' => 'node', + ); + + return $info; +} + +/** + * Implements hook_tokens(). + */ +function feeds_tokens($type, $tokens, array $data, array $options) { + $replacements = array(); + + if ($type == 'node' && !empty($data['node'])) { + + $sanitize = !empty($options['sanitize']); + + $feed_nid = feeds_get_feed_nid($data['node']->nid, 'node'); + + if ($feed_nid && $feed_source = node_load($feed_nid)) { + foreach ($tokens as $name => $original) { + switch ($name) { + case 'feed-source': + $replacements[$original] = $sanitize ? check_plain($feed_source->title) : $feed_source->title; + break; + } + } + + // Chained node token relationships. + if ($feed_source_tokens = token_find_with_prefix($tokens, 'feed-source')) { + $replacements += token_generate('node', $feed_source_tokens, array('node' => $feed_source), $options); + } + } + } + + return $replacements; +} diff --git a/sites/all/modules/feeds/feeds_import/feeds_import.features.inc b/sites/all/modules/feeds/feeds_import/feeds_import.features.inc new file mode 100644 index 0000000000000000000000000000000000000000..7cb263f85005a49c3509131341d3d2440aeabb4e --- /dev/null +++ b/sites/all/modules/feeds/feeds_import/feeds_import.features.inc @@ -0,0 +1,15 @@ +<?php +/** + * @file + * feeds_import.features.inc + */ + +/** + * Implementation of hook_ctools_plugin_api(). + */ +function feeds_import_ctools_plugin_api() { + list($module, $api) = func_get_args(); + if ($module == "feeds" && $api == "feeds_importer_default") { + return array("version" => 1); + } +} diff --git a/sites/all/modules/feeds/feeds_import/feeds_import.feeds_importer_default.inc b/sites/all/modules/feeds/feeds_import/feeds_import.feeds_importer_default.inc new file mode 100644 index 0000000000000000000000000000000000000000..7b1f68148875f2a971adf6332f2780ef924ec139 --- /dev/null +++ b/sites/all/modules/feeds/feeds_import/feeds_import.feeds_importer_default.inc @@ -0,0 +1,125 @@ +<?php +/** + * @file + * feeds_import.feeds_importer_default.inc + */ + +/** + * Implementation of hook_feeds_importer_default(). + */ +function feeds_import_feeds_importer_default() { + $export = array(); + + $feeds_importer = new stdClass; + $feeds_importer->disabled = FALSE; /* Edit this to true to make a default feeds_importer disabled initially */ + $feeds_importer->api_version = 1; + $feeds_importer->id = 'node'; + $feeds_importer->config = array( + 'name' => 'Node import', + 'description' => 'Import nodes from CSV file.', + 'fetcher' => array( + 'plugin_key' => 'FeedsFileFetcher', + 'config' => array( + 'direct' => FALSE, + ), + ), + 'parser' => array( + 'plugin_key' => 'FeedsCSVParser', + 'config' => array( + 'delimiter' => ',', + ), + ), + 'processor' => array( + 'plugin_key' => 'FeedsNodeProcessor', + 'config' => array( + 'content_type' => 'article', + 'update_existing' => 1, + 'expire' => '-1', + 'mappings' => array( + 0 => array( + 'source' => 'title', + 'target' => 'title', + 'unique' => FALSE, + ), + 1 => array( + 'source' => 'body', + 'target' => 'body', + 'unique' => FALSE, + ), + 2 => array( + 'source' => 'published', + 'target' => 'created', + 'unique' => FALSE, + ), + 3 => array( + 'source' => 'guid', + 'target' => 'guid', + 'unique' => 1, + ), + ), + 'input_format' => 'plain_text', + 'author' => 0, + ), + ), + 'content_type' => '', + 'update' => 0, + 'import_period' => '-1', + 'expire_period' => 3600, + 'import_on_create' => 1, + ); + $export['node'] = $feeds_importer; + + $feeds_importer = new stdClass; + $feeds_importer->disabled = FALSE; /* Edit this to true to make a default feeds_importer disabled initially */ + $feeds_importer->api_version = 1; + $feeds_importer->id = 'user'; + $feeds_importer->config = array( + 'name' => 'User import', + 'description' => 'Import users from CSV file.', + 'fetcher' => array( + 'plugin_key' => 'FeedsFileFetcher', + 'config' => array( + 'direct' => FALSE, + ), + ), + 'parser' => array( + 'plugin_key' => 'FeedsCSVParser', + 'config' => array( + 'delimiter' => ',', + ), + ), + 'processor' => array( + 'plugin_key' => 'FeedsUserProcessor', + 'config' => array( + 'roles' => array(), + 'update_existing' => FALSE, + 'status' => 1, + 'mappings' => array( + 0 => array( + 'source' => 'name', + 'target' => 'name', + 'unique' => 0, + ), + 1 => array( + 'source' => 'mail', + 'target' => 'mail', + 'unique' => 1, + ), + 2 => array( + 'source' => 'created', + 'target' => 'created', + 'unique' => FALSE, + ), + ), + ), + ), + 'content_type' => '', + 'update' => 0, + 'import_period' => '-1', + 'expire_period' => 3600, + 'import_on_create' => 1, + ); + $export['user'] = $feeds_importer; + + return $export; +} diff --git a/sites/all/modules/feeds/feeds_import/feeds_import.info b/sites/all/modules/feeds/feeds_import/feeds_import.info new file mode 100644 index 0000000000000000000000000000000000000000..1493ce1e840589e6a275ad785c53bc101bb31a94 --- /dev/null +++ b/sites/all/modules/feeds/feeds_import/feeds_import.info @@ -0,0 +1,17 @@ +core = "7.x" +dependencies[] = "feeds" +description = "An example of a node importer and a user importer." +features[ctools][] = "feeds:feeds_importer_default:1" +features[feeds_importer][] = "node" +features[feeds_importer][] = "user" +files[] = "feeds_import.test" +name = "Feeds Import" +package = "Feeds" +php = "5.2.4" + +; Information added by drupal.org packaging script on 2012-10-24 +version = "7.x-2.0-alpha7" +core = "7.x" +project = "feeds" +datestamp = "1351111319" + diff --git a/sites/all/modules/feeds/feeds_import/feeds_import.module b/sites/all/modules/feeds/feeds_import/feeds_import.module new file mode 100644 index 0000000000000000000000000000000000000000..044a4f1c81bbaaef5edf709f5c041af56af34435 --- /dev/null +++ b/sites/all/modules/feeds/feeds_import/feeds_import.module @@ -0,0 +1,8 @@ +<?php + +/** + * @file + * Empty module file. + */ + +include_once('feeds_import.features.inc'); diff --git a/sites/all/modules/feeds/feeds_import/feeds_import.test b/sites/all/modules/feeds/feeds_import/feeds_import.test new file mode 100644 index 0000000000000000000000000000000000000000..ee62de0e81c99c0aba49e1fe8071b1e3e139fe11 --- /dev/null +++ b/sites/all/modules/feeds/feeds_import/feeds_import.test @@ -0,0 +1,145 @@ +<?php + +/** + * @file + * Tests for feeds_import feature. + */ + +/** + * Test Node import configuration. + */ +class FeedsExamplesNodeTestCase extends FeedsWebTestCase { + + /** + * Set up test. + */ + public function setUp() { + parent::setUp(array('feeds_import')); + } + + public static function getInfo() { + return array( + 'name' => 'Feature: Node import', + 'description' => 'Test "Node import" default configuration.', + 'group' => 'Feeds', + ); + } + + /** + * Run tests. + */ + public function test() { + // Import file. + $this->importFile('node', $this->absolutePath() . '/tests/feeds/nodes.csv'); + + // Assert returning page. + $this->assertText('Created 8 nodes'); + $this->assertText('Import CSV files with one or more of these columns: title, body, published, guid.'); + $this->assertText('Column guid is mandatory and considered unique: only one item per guid value will be created.'); + $this->assertRaw('feeds/nodes.csv'); + + // Assert created nodes. + $this->drupalGet('node'); + $this->assertText('Typi non habent'); + $this->assertText('Eodem modo typi'); + $this->assertText('Eodem modo typi, qui nunc nobis videntur parum clari, fiant sollemnes in futurum.'); + $this->assertText('Lorem ipsum'); + $this->assertText('Ut wisi enim ad minim veniam'); + $this->assertText('1976'); + // Nam liber tempor has the same GUID as Lorem ipsum. + $this->assertNoText('Nam liber tempor'); + + // Click through to one node. + $this->clickLink('Lorem ipsum'); + $this->assertText('Lorem ipsum'); + $this->assertText('Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat.'); + $this->assertText('Anonymous'); + + // Assert DB status as is and again after an additional import. + for ($i = 0; $i < 2; $i++) { + $count = db_query("SELECT COUNT(*) FROM {feeds_item} WHERE entity_type = 'node'")->fetchField(); + $this->assertEqual($count, 8, 'Found correct number of items.'); + $count = db_query("SELECT COUNT(*) FROM {node} WHERE type = 'article' AND status = 1 AND uid = 0")->fetchField(); + $this->assertEqual($count, 8, 'Found correct number of items.'); + // Do not filter on type intentionally. There shouldn't be more than 8 nodes total. + $count = db_query("SELECT COUNT(*) FROM {node_revision}")->fetchField(); + $this->assertEqual($count, 8, 'Found correct number of items.'); + + // Import again. Feeds only updates items that haven't changed. However, + // there are 2 different items with the same GUID in nodes.csv. + // Therefore, feeds will show updates to 2 nodes. + $this->drupalPost('import/node/import', array(), 'Import'); + $this->assertText('Updated 2 nodes'); + } + + // Remove all nodes. + $this->drupalPost('import/node/delete-items', array(), 'Delete'); + $this->assertText('Deleted 8 nodes'); + + // Import once again. + $this->drupalPost('import/node/import', array(), 'Import'); + $this->assertText('Created 8 nodes'); + + // Import a similar file with changes in 4 records. Feeds should report 6 + // Updated Article nodes (4 changed records, 2 records sharing a GUID + // subsequently being updated). + $this->importFile('node', $this->absolutePath() . '/tests/feeds/nodes_changes.csv'); + $this->assertText('Updated 6 nodes'); + + // Import a larger file with more records. + $this->importFile('node', $this->absolutePath() . '/tests/feeds/many_nodes.csv'); + $this->assertText('Created 71 nodes'); + + // Remove all nodes. + $this->drupalPost('import/node/delete-items', array(), 'Delete'); + $this->assertText('Deleted 79 nodes'); + + // Import once again. + $this->drupalPost('import/node/import', array(), 'Import'); + $this->assertText('Created 79 nodes'); + $this->assertText('Updated 7 nodes'); + + // Import a tab separated file. + $this->drupalPost('import/node/delete-items', array(), 'Delete'); + $edit = array( + 'files[feeds]' => $this->absolutePath() . '/tests/feeds/nodes.tsv', + 'feeds[FeedsCSVParser][delimiter]' => "TAB", + ); + $this->drupalPost('import/node', $edit, 'Import'); + $this->assertText('Created 8 nodes'); + } +} + +/** + * Test User import configuration. + */ +class FeedsExamplesUserTestCase extends FeedsWebTestCase { + public static function getInfo() { + return array( + 'name' => 'Feature: User import', + 'description' => 'Test "User import" default configuration.', + 'group' => 'Feeds', + ); + } + + public function setUp() { + parent::setUp(array('feeds_import')); + } + + /** + * Run tests. + */ + public function test() { + // Import CSV file. + $this->importFile('user', $this->absolutePath() . '/tests/feeds/users.csv'); + + // Assert result. + $this->assertText('Created 3 users'); + // 1 user has an invalid email address. + $this->assertText('Failed importing 2 users'); + $this->drupalGet('admin/people'); + $this->assertText('Morticia'); + $this->assertText('Fester'); + $this->assertText('Gomez'); + } +} diff --git a/sites/all/modules/feeds/feeds_news/feeds_news.features.field.inc b/sites/all/modules/feeds/feeds_news/feeds_news.features.field.inc new file mode 100644 index 0000000000000000000000000000000000000000..6f01c9d9d6faba03b2ecf4773a6a68e008227c0e --- /dev/null +++ b/sites/all/modules/feeds/feeds_news/feeds_news.features.field.inc @@ -0,0 +1,101 @@ +<?php +/** + * @file + * feeds_news.features.field.inc + */ + +/** + * Implementation of hook_field_default_fields(). + */ +function feeds_news_field_default_fields() { + $fields = array(); + + // Exported field: 'node-feed_item-field_feed_item_description' + $fields['node-feed_item-field_feed_item_description'] = array( + 'field_config' => array( + 'active' => '1', + 'cardinality' => '1', + 'deleted' => '0', + 'entity_types' => array(), + 'field_name' => 'field_feed_item_description', + 'foreign keys' => array( + 'format' => array( + 'columns' => array( + 'format' => 'format', + ), + 'table' => 'filter_format', + ), + ), + 'indexes' => array( + 'format' => array( + 0 => 'format', + ), + ), + 'module' => 'text', + 'settings' => array(), + 'translatable' => '1', + 'type' => 'text_with_summary', + ), + 'field_instance' => array( + 'bundle' => 'feed_item', + 'default_value' => NULL, + 'deleted' => '0', + 'description' => '', + 'display' => array( + 'default' => array( + 'label' => 'hidden', + 'module' => 'text', + 'settings' => array(), + 'type' => 'text_default', + 'weight' => '0', + ), + 'full' => array( + 'label' => 'above', + 'settings' => array(), + 'type' => 'hidden', + 'weight' => 0, + ), + 'rss' => array( + 'label' => 'above', + 'settings' => array(), + 'type' => 'hidden', + 'weight' => 0, + ), + 'teaser' => array( + 'label' => 'hidden', + 'module' => 'text', + 'settings' => array( + 'trim_length' => 600, + ), + 'type' => 'text_trimmed', + 'weight' => '0', + ), + ), + 'entity_type' => 'node', + 'field_name' => 'field_feed_item_description', + 'label' => 'Description', + 'required' => 0, + 'settings' => array( + 'display_summary' => 0, + 'text_processing' => '1', + 'user_register_form' => FALSE, + ), + 'widget' => array( + 'active' => 1, + 'module' => 'text', + 'settings' => array( + 'rows' => '20', + 'summary_rows' => 5, + ), + 'type' => 'text_textarea_with_summary', + 'weight' => '-4', + ), + ), + ); + + // Translatables + // Included for use with string extractors like potx. + t('Description'); + + return $fields; +} diff --git a/sites/all/modules/feeds/feeds_news/feeds_news.features.inc b/sites/all/modules/feeds/feeds_news/feeds_news.features.inc new file mode 100644 index 0000000000000000000000000000000000000000..f7886eeeb393eb9d48c585485598d9d0102236d4 --- /dev/null +++ b/sites/all/modules/feeds/feeds_news/feeds_news.features.inc @@ -0,0 +1,50 @@ +<?php +/** + * @file + * feeds_news.features.inc + */ + +/** + * Implementation of hook_ctools_plugin_api(). + */ +function feeds_news_ctools_plugin_api() { + list($module, $api) = func_get_args(); + if ($module == "feeds" && $api == "feeds_importer_default") { + return array("version" => 1); + } +} + +/** + * Implementation of hook_views_api(). + */ +function feeds_news_views_api() { + list($module, $api) = func_get_args(); + if ($module == "views" && $api == "views_default") { + return array("version" => 3.0); + } +} + +/** + * Implementation of hook_node_info(). + */ +function feeds_news_node_info() { + $items = array( + 'feed' => array( + 'name' => t('Feed'), + 'base' => 'node_content', + 'description' => t('Subscribe to RSS or Atom feeds. Creates nodes of the content type "Feed item" from feed content.'), + 'has_title' => '1', + 'title_label' => t('Title'), + 'help' => '', + ), + 'feed_item' => array( + 'name' => t('Feed item'), + 'base' => 'node_content', + 'description' => t('This content type is being used for automatically aggregated content from feeds.'), + 'has_title' => '1', + 'title_label' => t('Title'), + 'help' => '', + ), + ); + return $items; +} diff --git a/sites/all/modules/feeds/feeds_news/feeds_news.feeds_importer_default.inc b/sites/all/modules/feeds/feeds_news/feeds_news.feeds_importer_default.inc new file mode 100644 index 0000000000000000000000000000000000000000..f42ad0ca5e57b556dfe0dc12d7424719b0a11033 --- /dev/null +++ b/sites/all/modules/feeds/feeds_news/feeds_news.feeds_importer_default.inc @@ -0,0 +1,124 @@ +<?php +/** + * @file + * feeds_news.feeds_importer_default.inc + */ + +/** + * Implementation of hook_feeds_importer_default(). + */ +function feeds_news_feeds_importer_default() { + $export = array(); + + $feeds_importer = new stdClass; + $feeds_importer->disabled = FALSE; /* Edit this to true to make a default feeds_importer disabled initially */ + $feeds_importer->api_version = 1; + $feeds_importer->id = 'feed'; + $feeds_importer->config = array( + 'name' => 'Feed', + 'description' => 'Import RSS or Atom feeds, create nodes from feed items.', + 'fetcher' => array( + 'plugin_key' => 'FeedsHTTPFetcher', + 'config' => array( + 'auto_detect_feeds' => 1, + 'use_pubsubhubbub' => 0, + 'designated_hub' => '', + ), + ), + 'parser' => array( + 'plugin_key' => 'FeedsSyndicationParser', + 'config' => array(), + ), + 'processor' => array( + 'plugin_key' => 'FeedsNodeProcessor', + 'config' => array( + 'content_type' => 'feed_item', + 'update_existing' => '0', + 'expire' => '-1', + 'mappings' => array( + 0 => array( + 'source' => 'title', + 'target' => 'title', + 'unique' => FALSE, + ), + 1 => array( + 'source' => 'timestamp', + 'target' => 'created', + 'unique' => FALSE, + ), + 2 => array( + 'source' => 'url', + 'target' => 'url', + 'unique' => 1, + ), + 3 => array( + 'source' => 'guid', + 'target' => 'guid', + 'unique' => 1, + ), + 4 => array( + 'source' => 'description', + 'target' => 'field_feed_item_description', + 'unique' => FALSE, + ), + ), + 'input_format' => 'filtered_html', + 'author' => 0, + ), + ), + 'content_type' => 'feed', + 'update' => 0, + 'import_period' => '1800', + 'expire_period' => 3600, + 'import_on_create' => 1, + 'process_in_background' => FALSE, + ); + $export['feed'] = $feeds_importer; + + $feeds_importer = new stdClass; + $feeds_importer->disabled = FALSE; /* Edit this to true to make a default feeds_importer disabled initially */ + $feeds_importer->api_version = 1; + $feeds_importer->id = 'opml'; + $feeds_importer->config = array( + 'name' => 'OPML import', + 'description' => 'Import subscriptions from OPML files. Use together with "Feed" configuration.', + 'fetcher' => array( + 'plugin_key' => 'FeedsFileFetcher', + 'config' => array( + 'direct' => FALSE, + ), + ), + 'parser' => array( + 'plugin_key' => 'FeedsOPMLParser', + 'config' => array(), + ), + 'processor' => array( + 'plugin_key' => 'FeedsNodeProcessor', + 'config' => array( + 'content_type' => 'feed', + 'update_existing' => 0, + 'expire' => '-1', + 'mappings' => array( + 0 => array( + 'source' => 'title', + 'target' => 'title', + 'unique' => FALSE, + ), + 1 => array( + 'source' => 'xmlurl', + 'target' => 'feeds_source', + 'unique' => 1, + ), + ), + ), + ), + 'content_type' => '', + 'update' => 0, + 'import_period' => '-1', + 'expire_period' => 3600, + 'import_on_create' => 1, + ); + $export['opml'] = $feeds_importer; + + return $export; +} diff --git a/sites/all/modules/feeds/feeds_news/feeds_news.info b/sites/all/modules/feeds/feeds_news/feeds_news.info new file mode 100644 index 0000000000000000000000000000000000000000..f1cbf49afae1dda3654dcab1ec722ea8cd4cefc2 --- /dev/null +++ b/sites/all/modules/feeds/feeds_news/feeds_news.info @@ -0,0 +1,25 @@ +core = "7.x" +dependencies[] = "features" +dependencies[] = "feeds" +dependencies[] = "views" +description = "A news aggregator built with feeds, creates nodes from imported feed items. With OPML import." +features[ctools][] = "feeds:feeds_importer_default:1" +features[ctools][] = "views:views_default:3.0" +features[feeds_importer][] = "feed" +features[feeds_importer][] = "opml" +features[field][] = "node-feed_item-field_feed_item_description" +features[node][] = "feed" +features[node][] = "feed_item" +features[views_view][] = "feeds_defaults_feed_items" +files[] = "feeds_news.module" +files[] = "feeds_news.test" +name = "Feeds News" +package = "Feeds" +php = "5.2.4" + +; Information added by drupal.org packaging script on 2012-10-24 +version = "7.x-2.0-alpha7" +core = "7.x" +project = "feeds" +datestamp = "1351111319" + diff --git a/sites/all/modules/feeds/feeds_news/feeds_news.module b/sites/all/modules/feeds/feeds_news/feeds_news.module new file mode 100644 index 0000000000000000000000000000000000000000..13109760106cc448c75ecbba8780381233565438 --- /dev/null +++ b/sites/all/modules/feeds/feeds_news/feeds_news.module @@ -0,0 +1,8 @@ +<?php + +/** + * @file + * Empty module file. + */ + +include_once('feeds_news.features.inc'); diff --git a/sites/all/modules/feeds/feeds_news/feeds_news.test b/sites/all/modules/feeds/feeds_news/feeds_news.test new file mode 100644 index 0000000000000000000000000000000000000000..8a2a5071b34643103ed783f275ecdf9f6791ffae --- /dev/null +++ b/sites/all/modules/feeds/feeds_news/feeds_news.test @@ -0,0 +1,127 @@ +<?php + +/** + * @file + * Tests for feeds_news feature. + */ + +/** + * Test Feed configuration. + */ +class FeedsExamplesFeedTestCase extends FeedsWebTestCase { + public static function getInfo() { + return array( + 'name' => 'Feature: Feed', + 'description' => 'Test "Feed" default configuration.', + 'group' => 'Feeds', + 'dependencies' => array('features', 'views'), + ); + } + + public function setUp() { + parent::setUp(array('features', 'views', 'feeds_news')); + } + + /** + * Run tests. + */ + public function test() { + $nid = $this->createFeedNode('feed', NULL, '', 'feed'); + + // Assert menu tabs for feed nodes does not show up on non-feed nodes. + $this->drupalGet("node/{$nid}/feed-items"); + $this->assertResponse(200); + $not_feed_node = $this->drupalCreateNode(); + $this->drupalGet("node/{$not_feed_node->nid}/feed-items"); + $this->assertResponse(404); + + // Assert results. + $count = db_query("SELECT COUNT(*) FROM {node} WHERE type = 'feed_item'")->fetchField(); + $this->assertEqual($count, 10, 'Found the correct number of feed item nodes in database.'); + + $count = db_query("SELECT COUNT(*) FROM {feeds_item} WHERE entity_type = 'node'")->fetchField(); + $this->assertEqual($count, 10, 'Found the correct number of records in feeds_item.'); + + $count = db_query("SELECT COUNT(*) FROM {node} WHERE title = 'Open Atrium Translation Workflow: Two Way Translation Updates'")->fetchField(); + $this->assertEqual($count, 1, 'Found title.'); + $count = db_query("SELECT COUNT(*) FROM {node} WHERE title = 'Week in DC Tech: October 5th Edition'")->fetchField(); + $this->assertEqual($count, 1, 'Found title.'); + $count = db_query("SELECT COUNT(*) FROM {node} WHERE title = 'Integrating the Siteminder Access System in an Open Atrium-based Intranet'")->fetchField(); + $this->assertEqual($count, 1, 'Found title.'); + $count = db_query("SELECT COUNT(*) FROM {node} WHERE title = 'Scaling the Open Atrium UI'")->fetchField(); + $this->assertEqual($count, 1, 'Found title.'); + + $count = db_query("SELECT COUNT(*) FROM {feeds_item} WHERE entity_type = 'node' AND url = 'http://developmentseed.org/blog/2009/oct/06/open-atrium-translation-workflow-two-way-updating'")->fetchField(); + $this->assertEqual($count, 1, 'Found feed_node_item record.'); + $count = db_query("SELECT COUNT(*) FROM {feeds_item} WHERE entity_type = 'node' AND url = 'http://developmentseed.org/blog/2009/oct/05/week-dc-tech-october-5th-edition'")->fetchField(); + $this->assertEqual($count, 1, 'Found feed_node_item record.'); + $count = db_query("SELECT COUNT(*) FROM {feeds_item} WHERE entity_type = 'node' AND guid = '974 at http://developmentseed.org'")->fetchField(); + $this->assertEqual($count, 1, 'Found feed_node_item record.'); + $count = db_query("SELECT COUNT(*) FROM {feeds_item} WHERE entity_type = 'node' AND guid = '970 at http://developmentseed.org'")->fetchField(); + $this->assertEqual($count, 1, 'Found feed_node_item record.'); + + // Remove all items + $this->drupalPost("node/$nid/delete-items", array(), 'Delete'); + $this->assertText('Deleted 10 nodes'); + + // Import again. + $this->drupalPost("node/$nid/import", array(), 'Import'); + $this->assertText('Created 10 nodes'); + + // Delete and assert all items gone. + $this->drupalPost("node/$nid/delete-items", array(), 'Delete'); + + $count = db_query("SELECT COUNT(*) FROM {node} WHERE type = 'feed_item'")->fetchField(); + $this->assertEqual($count, 0, 'Found the correct number of feed item nodes in database.'); + + $count = db_query("SELECT COUNT(*) FROM {feeds_item} WHERE entity_type = 'node'")->fetchField(); + $this->assertEqual($count, 0, 'Found the correct number of records in feeds_item.'); + + // Create a batch of nodes. + $this->createFeedNodes('feed', 10, 'feed'); + $count = db_query("SELECT COUNT(*) FROM {node} WHERE type = 'feed_item'")->fetchField(); + $this->assertEqual($count, 100, 'Imported 100 nodes.'); + $count = db_query("SELECT COUNT(*) FROM {feeds_item} WHERE entity_type = 'node'")->fetchField(); + $this->assertEqual($count, 100, 'Found 100 records in feeds_item.'); + } +} + +/** + * Test OPML import configuration. + */ +class FeedsExamplesOPMLTestCase extends FeedsWebTestCase { + + public static function getInfo() { + return array( + 'name' => 'Feature: OPML import', + 'description' => 'Test "OPML import" default configuration.', + 'group' => 'Feeds', + ); + } + + /** + * Enable feeds_news feature. + */ + public function setUp() { + parent::setUp(array('feeds_news')); + } + + /** + * Run tests. + */ + public function test() { + + // Import OPML and assert. + $file = $this->generateOPML(); + $this->importFile('opml', $file); + $this->assertText('Created 3 nodes'); + $count = db_query("SELECT COUNT(*) FROM {feeds_source}")->fetchField(); + $this->assertEqual($count, 4, 'Found correct number of items.'); + + // Import a feed and then delete all items from it. + $this->drupalPost('node/1/import', array(), 'Import'); + $this->assertText('Created 10 nodes'); + $this->drupalPost('node/1/delete-items', array(), 'Delete'); + $this->assertText('Deleted 10 nodes'); + } +} diff --git a/sites/all/modules/feeds/feeds_news/feeds_news.views_default.inc b/sites/all/modules/feeds/feeds_news/feeds_news.views_default.inc new file mode 100644 index 0000000000000000000000000000000000000000..8736f5768473452a6a94f6408e34b5d3c39b1f3e --- /dev/null +++ b/sites/all/modules/feeds/feeds_news/feeds_news.views_default.inc @@ -0,0 +1,104 @@ +<?php +/** + * @file + * feeds_news.views_default.inc + */ + +/** + * Implementation of hook_views_default_views(). + */ +function feeds_news_views_default_views() { + $export = array(); + + $view = new view; + $view->name = 'feeds_defaults_feed_items'; + $view->description = 'Show feed items for a feed node. Use together with default importer configuration "Feed".'; + $view->tag = 'Feeds defaults'; + $view->base_table = 'node'; + $view->human_name = ''; + $view->core = 0; + $view->api_version = '3.0-alpha1'; + $view->disabled = FALSE; /* Edit this to true to make a default view disabled initially */ + + /* Display: Defaults */ + $handler = $view->new_display('default', 'Defaults', 'default'); + $handler->display->display_options['access']['type'] = 'perm'; + $handler->display->display_options['cache']['type'] = 'none'; + $handler->display->display_options['query']['type'] = 'views_query'; + $handler->display->display_options['query']['options']['query_comment'] = FALSE; + $handler->display->display_options['exposed_form']['type'] = 'basic'; + $handler->display->display_options['pager']['type'] = 'full'; + $handler->display->display_options['style_plugin'] = 'default'; + $handler->display->display_options['row_plugin'] = 'node'; + $handler->display->display_options['row_options']['links'] = 1; + $handler->display->display_options['row_options']['comments'] = 0; + /* No results behavior: Global: Text area */ + $handler->display->display_options['empty']['text']['id'] = 'area'; + $handler->display->display_options['empty']['text']['table'] = 'views'; + $handler->display->display_options['empty']['text']['field'] = 'area'; + $handler->display->display_options['empty']['text']['empty'] = FALSE; + $handler->display->display_options['empty']['text']['content'] = 'There are no items for this feed at the moment.'; + $handler->display->display_options['empty']['text']['format'] = '1'; + /* Relationship: Feeds item: Owner feed */ + $handler->display->display_options['relationships']['feed_nid_1']['id'] = 'feed_nid_1'; + $handler->display->display_options['relationships']['feed_nid_1']['table'] = 'feeds_item'; + $handler->display->display_options['relationships']['feed_nid_1']['field'] = 'feed_nid'; + $handler->display->display_options['relationships']['feed_nid_1']['required'] = 1; + /* Sort criterion: Content: Post date */ + $handler->display->display_options['sorts']['created']['id'] = 'created'; + $handler->display->display_options['sorts']['created']['table'] = 'node'; + $handler->display->display_options['sorts']['created']['field'] = 'created'; + $handler->display->display_options['sorts']['created']['order'] = 'DESC'; + /* Contextual filter: Content: Nid */ + $handler->display->display_options['arguments']['nid']['id'] = 'nid'; + $handler->display->display_options['arguments']['nid']['table'] = 'node'; + $handler->display->display_options['arguments']['nid']['field'] = 'nid'; + $handler->display->display_options['arguments']['nid']['relationship'] = 'feed_nid_1'; + $handler->display->display_options['arguments']['nid']['default_action'] = 'not found'; + $handler->display->display_options['arguments']['nid']['title_enable'] = 1; + $handler->display->display_options['arguments']['nid']['title'] = 'Articles from %1'; + $handler->display->display_options['arguments']['nid']['default_argument_type'] = 'fixed'; + $handler->display->display_options['arguments']['nid']['summary']['format'] = 'default_summary'; + $handler->display->display_options['arguments']['nid']['specify_validation'] = 1; + $handler->display->display_options['arguments']['nid']['validate']['type'] = 'node'; + $handler->display->display_options['arguments']['nid']['validate_options']['types'] = array( + 'feed' => 'feed', + ); + $handler->display->display_options['arguments']['nid']['break_phrase'] = 0; + $handler->display->display_options['arguments']['nid']['not'] = 0; + /* Filter criterion: Content: Type */ + $handler->display->display_options['filters']['type']['id'] = 'type'; + $handler->display->display_options['filters']['type']['table'] = 'node'; + $handler->display->display_options['filters']['type']['field'] = 'type'; + $handler->display->display_options['filters']['type']['value'] = array( + 'feed_item' => 'feed_item', + ); + $handler->display->display_options['filters']['type']['expose']['operator'] = FALSE; + + /* Display: Page */ + $handler = $view->new_display('page', 'Page', 'page_1'); + $handler->display->display_options['path'] = 'node/%/feed-items'; + $handler->display->display_options['menu']['type'] = 'tab'; + $handler->display->display_options['menu']['title'] = 'View items'; + $handler->display->display_options['menu']['weight'] = '-9'; + $translatables['feeds_defaults_feed_items'] = array( + t('Defaults'), + t('more'), + t('Apply'), + t('Reset'), + t('Sort by'), + t('Asc'), + t('Desc'), + t('Items per page'), + t('- All -'), + t('Offset'), + t('There are no items for this feed at the moment.'), + t('Owner feed'), + t('All'), + t('Articles from %1'), + t('Page'), + ); + $export['feeds_defaults_feed_items'] = $view; + + return $export; +} diff --git a/sites/all/modules/feeds/feeds_ui/feeds_ui.admin.inc b/sites/all/modules/feeds/feeds_ui/feeds_ui.admin.inc new file mode 100644 index 0000000000000000000000000000000000000000..0cef587ed1a2982a8b294835504e4edb3f668508 --- /dev/null +++ b/sites/all/modules/feeds/feeds_ui/feeds_ui.admin.inc @@ -0,0 +1,1077 @@ +<?php + +/** + * @file + * Contains all page callbacks, forms and theming functions for Feeds + * administrative pages. + */ + +/** + * Introductory help for admin/structure/feeds/%feeds_importer page + */ +function feeds_ui_edit_help() { + return t(' + <p> + You can create as many Feeds importer configurations as you would like to. Each can have a distinct purpose like letting your users aggregate RSS feeds or importing a CSV file for content migration. Here are a couple of things that are important to understand in order to get started with Feeds: + </p> + <ul> + <li> + Every importer configuration consists of basic settings, a fetcher, a parser and a processor and their settings. + </li> + <li> + The <strong>basic settings</strong> define the general behavior of the importer. <strong>Fetchers</strong> are responsible for loading data, <strong>parsers</strong> for organizing it and <strong>processors</strong> for "doing stuff" with it, usually storing it. + </li> + <li> + In Basic settings, you can <strong>attach an importer configuration to a content type</strong>. This is useful when many imports of a kind should be created, for example in an RSS aggregation scenario. If you don\'t attach a configuration to a content type, you can use it on the !import page. + </li> + <li> + Imports can be <strong>scheduled periodically</strong> - see the periodic import select box in the Basic settings. + </li> + <li> + Processors can have <strong>mappings</strong> in addition to settings. Mappings allow you to define what elements of a data feed should be mapped to what content fields on a granular level. For instance, you can specify that a feed item\'s author should be mapped to a node\'s body. + </li> + </ul> + ', array('!import' => l(t('Import'), 'import'))); +} + +/** + * Help text for mapping. + */ +function feeds_ui_mapping_help() { + return t(' + <p> + Define which elements of a single item of a feed (= <em>Sources</em>) map to which content pieces in Drupal (= <em>Targets</em>). Make sure that at least one definition has a <em>Unique target</em>. A unique target means that a value for a target can only occur once. E. g. only one item with the URL <em>http://example.com/content/1</em> can exist. + </p> + '); +} + +/** + * Build overview of available configurations. + */ +function feeds_ui_overview_form($form, &$form_status) { + $form = $form['enabled'] = $form['disabled'] = array(); + + $form['#header'] = array( + t('Name'), + t('Description'), + t('Attached to'), + t('Status'), + t('Operations'), + t('Enabled'), + ); + foreach (feeds_importer_load_all(TRUE) as $importer) { + $importer_form = array(); + $importer_form['name']['#markup'] = check_plain($importer->config['name']); + $importer_form['description']['#markup'] = check_plain($importer->config['description']); + if (empty($importer->config['content_type'])) { + $importer_form['attached']['#markup'] = '[none]'; + } + else { + if (!$importer->disabled) { + $importer_form['attached']['#markup'] = l(node_type_get_name($importer->config['content_type']), 'node/add/' . str_replace('_', '-', $importer->config['content_type'])); + } + else { + $importer_form['attached']['#markup'] = check_plain(node_type_get_name($importer->config['content_type'])); + } + } + + if ($importer->export_type == EXPORT_IN_CODE) { + $status = t('Default'); + $edit = t('Override'); + $delete = ''; + } + elseif ($importer->export_type == EXPORT_IN_DATABASE) { + $status = t('Normal'); + $edit = t('Edit'); + $delete = t('Delete'); + } + elseif ($importer->export_type == (EXPORT_IN_CODE | EXPORT_IN_DATABASE)) { + $status = t('Overridden'); + $edit = t('Edit'); + $delete = t('Revert'); + } + $importer_form['status'] = array( + '#markup' => $status, + ); + if (!$importer->disabled) { + $importer_form['operations'] = array( + '#markup' => + l($edit, 'admin/structure/feeds/' . $importer->id) . ' | ' . + l(t('Export'), 'admin/structure/feeds/' . $importer->id . '/export') . ' | ' . + l(t('Clone'), 'admin/structure/feeds/' . $importer->id . '/clone') . + (empty($delete) ? '' : ' | ' . l($delete, 'admin/structure/feeds/' . $importer->id . '/delete')), + ); + } + else { + $importer_form['operations']['#markup'] = ' '; + } + + $importer_form[$importer->id] = array( + '#type' => 'checkbox', + '#default_value' => !$importer->disabled, + '#attributes' => array('class' => array('feeds-ui-trigger-submit')), + ); + + if ($importer->disabled) { + $form['disabled'][$importer->id] = $importer_form; + } + else { + $form['enabled'][$importer->id] = $importer_form; + } + } + $form['submit'] = array( + '#type' => 'submit', + '#value' => t('Save'), + '#attributes' => array('class' => array('feeds-ui-hidden-submit')), + ); + return $form; +} + +/** + * Submit handler for feeds_ui_overview_form(). + */ +function feeds_ui_overview_form_submit($form, &$form_state) { + + $disabled = array(); + foreach (feeds_importer_load_all(TRUE) as $importer) { + $disabled[$importer->id] = !$form_state['values'][$importer->id]; + } + variable_set('default_feeds_importer', $disabled); + feeds_cache_clear(); +} + +/** + * Create a new configuration. + * + * @param $form_state + * Form API form state array. + * @param $from_importer + * FeedsImporter object. If given, form will create a new importer as a copy + * of $from_importer. + */ +function feeds_ui_create_form($form, &$form_state, $from_importer = NULL) { + $form['#attached']['js'][] = drupal_get_path('module', 'feeds_ui') . '/feeds_ui.js'; + $form['#from_importer'] = $from_importer; + $form['name'] = array( + '#type' => 'textfield', + '#title' => t('Name'), + '#description' => t('A natural name for this configuration. Example: RSS Feed. You can always change this name later.'), + '#required' => TRUE, + '#maxlength' => 128, + ); + $form['id'] = array( + '#type' => 'machine_name', + '#required' => TRUE, + '#maxlength' => 128, + '#machine_name' => array( + 'exists' => 'feeds_ui_importer_machine_name_exists', + ), + ); + $form['description'] = array( + '#type' => 'textfield', + '#title' => t('Description'), + '#description' => t('A description of this configuration.'), + ); + $form['submit'] = array( + '#type' => 'submit', + '#value' => t('Create'), + ); + return $form; +} + +/** + * Validation callback for the importer machine name field. + */ +function feeds_ui_importer_machine_name_exists($id) { + if ($id == 'create') { + // Create is a reserved path for the add importer form. + return TRUE; + } + ctools_include('export'); + if (ctools_export_load_object('feeds_importer', 'conditions', array('id' => $id))) { + return TRUE; + } +} + +/** + * Validation handler for feeds_build_create_form(). + */ +function feeds_ui_create_form_validate($form, &$form_state) { + if (!empty($form_state['values']['id'])) { + $importer = feeds_importer($form_state['values']['id']); + $importer->configFormValidate($form_state['values']); + } +} + +/** + * Submit handler for feeds_build_create_form(). + */ +function feeds_ui_create_form_submit($form, &$form_state) { + // Create feed. + $importer = feeds_importer($form_state['values']['id']); + // If from_importer is given, copy its configuration. + if (!empty($form['#from_importer'])) { + $importer->copy($form['#from_importer']); + } + // In any case, we want to set this configuration's title and description. + $importer->addConfig($form_state['values']); + $importer->save(); + + // Set a message and redirect to settings form. + if (empty($form['#from_importer'])) { + drupal_set_message(t('Your configuration has been created with default settings. If they do not fit your use case you can adjust them here.')); + } + else { + drupal_set_message(t('A clone of the @name configuration has been created.', array('@name' => $form['#from_importer']->config['name']))); + } + $form_state['redirect'] = 'admin/structure/feeds/' . $importer->id; + feeds_cache_clear(); +} + +/** + * Delete configuration form. + */ +function feeds_ui_delete_form($form, &$form_state, $importer) { + $form['#importer'] = $importer->id; + if ($importer->export_type & EXPORT_IN_CODE) { + $title = t('Would you really like to revert the importer @importer?', array('@importer' => $importer->config['name'])); + $button_label = t('Revert'); + } + else { + $title = t('Would you really like to delete the importer @importer?', array('@importer' => $importer->config['name'])); + $button_label = t('Delete'); + } + return confirm_form( + $form, + $title, + 'admin/structure/feeds', + t('This action cannot be undone.'), + $button_label + ); +} + +/** + * Submit handler for feeds_ui_delete_form(). + */ +function feeds_ui_delete_form_submit($form, &$form_state) { + $form_state['redirect'] = 'admin/structure/feeds'; + + // Remove importer. + feeds_importer($form['#importer'])->delete(); + + // Clear cache, deleting a configuration may have an affect on menu tree. + feeds_cache_clear(); +} + +/** + * Export a feed configuration. + */ +function feeds_ui_export_form($form, &$form_state, $importer) { + $code = feeds_export($importer->id); + + $form['export'] = array( + '#title' => t('Export feed configuration'), + '#type' => 'textarea', + '#value' => $code, + '#rows' => substr_count($code, "\n"), + ); + return $form; +} + +/** + * Edit feed configuration. + */ +function feeds_ui_edit_page($importer, $active = 'help', $plugin_key = '') { + + // Get plugins and configuration. + $plugins = FeedsPlugin::all(); + $config = $importer->config; + // Base path for changing the active container. + $path = 'admin/structure/feeds/' . $importer->id; + + $active_container = array( + 'class' => array('active-container'), + 'actions' => array(l(t('Help'), $path)), + ); + switch ($active) { + case 'help': + $active_container['title'] = t('Getting started'); + $active_container['body'] = '<div class="help feeds-admin-ui">' . feeds_ui_edit_help() . '</div>'; + unset($active_container['actions']); + break; + case 'fetcher': + case 'parser': + case 'processor': + $active_container['title'] = t('Select a !plugin_type', array('!plugin_type' => $active)); + $active_container['body'] = drupal_get_form('feeds_ui_plugin_form', $importer, $active); + break; + case 'settings': + drupal_add_js(drupal_get_path('module', 'ctools') . '/js/dependent.js'); + ctools_include('dependent'); + if (empty($plugin_key)) { + $active_container['title'] = t('Basic settings'); + $active_container['body'] = feeds_get_form($importer, 'configForm'); + } + // feeds_plugin() returns a correct result because feed has been + // instantiated previously. + elseif (in_array($plugin_key, array_keys($plugins)) && $plugin = feeds_plugin($plugin_key, $importer->id)) { + $active_container['title'] = t('Settings for !plugin', array('!plugin' => $plugins[$plugin_key]['name'])); + $active_container['body'] = feeds_get_form($plugin, 'configForm'); + } + break; + case 'mapping': + $active_container['title'] = t('Mapping for !processor', array('!processor' => $plugins[$config['processor']['plugin_key']]['name'])); + $active_container['body'] = drupal_get_form('feeds_ui_mapping_form', $importer); + break; + } + + // Build config info. + $config_info = $info = array(); + $info['class'] = array('config-set'); + + // Basic information. + $items = array(); + $items[] = t('Attached to: @type', array('@type' => $importer->config['content_type'] ? node_type_get_name($importer->config['content_type']) : t('[none]'))); + if ($importer->config['import_period'] == FEEDS_SCHEDULE_NEVER) { + $import_period = t('off'); + } + elseif ($importer->config['import_period'] == 0) { + $import_period = t('as often as possible'); + } + else { + $import_period = t('every !interval', array('!interval' => format_interval($importer->config['import_period']))); + } + $items[] = t('Periodic import: !import_period', array('!import_period' => $import_period)); + $items[] = $importer->config['import_on_create'] ? t('Import on submission') : t('Do not import on submission'); + + $info['title'] = t('Basic settings'); + $info['body'] = array( + array( + 'body' => theme('item_list', array('items' => $items)), + 'actions' => array(l(t('Settings'), $path . '/settings')), + ), + ); + $config_info[] = $info; + + // Fetcher. + $fetcher = $plugins[$config['fetcher']['plugin_key']]; + $actions = array(); + if (feeds_get_form($importer->fetcher, 'configForm')) { + $actions = array(l(t('Settings'), $path . '/settings/' . $config['fetcher']['plugin_key'])); + } + $info['title'] = t('Fetcher'); + $info['body'] = array( + array( + 'title' => $fetcher['name'], + 'body' => $fetcher['description'], + 'actions' => $actions, + ), + ); + $info['actions'] = array(l(t('Change'), $path . '/fetcher')); + $config_info[] = $info; + + // Parser. + $parser = $plugins[$config['parser']['plugin_key']]; + $actions = array(); + if (feeds_get_form($importer->parser, 'configForm')) { + $actions = array(l(t('Settings'), $path . '/settings/' . $config['parser']['plugin_key'])); + } + $info['title'] = t('Parser'); + $info['body'] = array( + array( + 'title' => $parser['name'], + 'body' => $parser['description'], + 'actions' => $actions, + ) + ); + $info['actions'] = array(l(t('Change'), $path . '/parser')); + $config_info[] = $info; + + // Processor. + $processor = $plugins[$config['processor']['plugin_key']]; + $actions = array(); + if (feeds_get_form($importer->processor, 'configForm')) { + $actions[] = l(t('Settings'), $path . '/settings/' . $config['processor']['plugin_key']); + } + $actions[] = l(t('Mapping'), $path . '/mapping'); + $info['title'] = t('Processor'); + $info['body'] = array( + array( + 'title' => $processor['name'], + 'body' => $processor['description'], + 'actions' => $actions, + ) + ); + $info['actions'] = array(l(t('Change'), $path . '/processor')); + $config_info[] = $info; + + return theme('feeds_ui_edit_page', array( + 'info' => $config_info, + 'active' => $active_container, + )); +} + +/** + * Build a form of plugins to pick from. + * + * @param $form_state + * Form API form state array. + * @param $importer + * FeedsImporter object. + * @param $type + * Plugin type. One of 'fetcher', 'parser', 'processor'. + * + * @return + * A Form API form definition. + */ +function feeds_ui_plugin_form($form, &$form_state, $importer, $type) { + $plugins = FeedsPlugin::byType($type); + + $form = array(); + $form['#importer'] = $importer->id; + $form['#plugin_type'] = $type; + + foreach ($plugins as $key => $plugin) { + $form['plugin_key'][$key] = array( + '#type' => 'radio', + '#parents' => array('plugin_key'), + '#title' => check_plain($plugin['name']), + '#description' => filter_xss(isset($plugin['help']) ? $plugin['help'] : $plugin['description']), + '#return_value' => $key, + '#default_value' => ($plugin['handler']['class'] == get_class($importer->$type)) ? $key : '', + ); + } + $form['submit'] = array( + '#type' => 'submit', + '#value' => t('Save'), + '#attributes' => array('class' => array('feeds-ui-hidden-submit')), + ); + return $form; +} + +/** + * Submit handler for feeds_ui_plugin_form(). + */ +function feeds_ui_plugin_form_submit($form, &$form_state) { + // Set the plugin and save feed. + $importer = feeds_importer($form['#importer']); + $importer->setPlugin($form_state['values']['plugin_key']); + $importer->save(); + drupal_set_message(t('Changed @type plugin.', array('@type' => $form['#plugin_type']))); +} + +/** + * Theme feeds_ui_plugin_form(). + */ +function theme_feeds_ui_plugin_form($variables) { + $form = $variables['form']; + drupal_add_js(drupal_get_path('module', 'feeds_ui') . '/feeds_ui.js'); + $output = ''; + + foreach (element_children($form['plugin_key']) as $key) { + + // Assemble container, render form elements. + $container = array( + 'title' => $form['plugin_key'][$key]['#title'], + 'body' => isset($form['plugin_key'][$key]['#description']) ? $form['plugin_key'][$key]['#description'] : '', + ); + $form['plugin_key'][$key]['#title'] = t('Select'); + $form['plugin_key'][$key]['#attributes']['class'] = array('feeds-ui-radio-link'); + unset($form['plugin_key'][$key]['#description']); + $container['actions'] = array(drupal_render($form['plugin_key'][$key])); + + $output .= theme('feeds_ui_container', array('container' => $container)); + } + + $output .= drupal_render_children($form); + return $output; +} + +/** + * Edit mapping. + * + * @todo Completely merge this into config form handling. This is just a + * shared form of configuration, most of the common functionality can live in + * FeedsProcessor, a flag can tell whether mapping is supported or not. + */ +function feeds_ui_mapping_form($form, &$form_state, $importer) { + drupal_add_js(drupal_get_path('module', 'feeds_ui') . '/feeds_ui.js'); + + $form = array(); + $form['#importer'] = $importer->id; + $form['#mappings'] = $mappings = $importer->processor->getMappings(); + $form['help']['#markup'] = feeds_ui_mapping_help(); + $form['#prefix'] = '<div id="feeds-ui-mapping-form-wrapper">'; + $form['#suffix'] = '</div>'; + + // Get mapping sources from parsers and targets from processor, format them + // for output. + // Some parsers do not define mapping sources but let them define on the fly. + if ($sources = $importer->parser->getMappingSources()) { + $source_options = _feeds_ui_format_options($sources); + foreach ($sources as $k => $source) { + $legend['sources'][$k]['name']['#markup'] = empty($source['name']) ? $k : $source['name']; + $legend['sources'][$k]['description']['#markup'] = empty($source['description']) ? '' : $source['description']; + } + } + else { + $legend['sources']['#markup'] = t('This parser supports free source definitions. Enter the name of the source field in lower case into the Source text field above.'); + } + $targets = $importer->processor->getMappingTargets(); + $target_options = _feeds_ui_format_options($targets); + $legend['targets'] = array(); + foreach ($targets as $k => $target) { + $legend['targets'][$k]['name']['#markup'] = empty($target['name']) ? $k : $target['name']; + $legend['targets'][$k]['description']['#markup'] = empty($target['description']) ? '' : $target['description']; + } + + // Legend explaining source and target elements. + $form['legendset'] = array( + '#type' => 'fieldset', + '#title' => t('Legend'), + '#collapsible' => TRUE, + '#collapsed' => TRUE, + '#tree' => TRUE, + ); + $form['legendset']['legend'] = $legend; + + // Add config forms and remove flags to mappings. + $form['config'] = $form['remove_flags'] = $form['mapping_weight'] = array( + '#tree' => TRUE, + ); + if (is_array($mappings)) { + + $delta = count($mappings) + 2; + + foreach ($mappings as $i => $mapping) { + if (isset($targets[$mapping['target']])) { + $form['config'][$i] = feeds_ui_mapping_settings_form($form, $form_state, $i, $mapping, $targets[$mapping['target']]); + } + + $form['remove_flags'][$i] = array( + '#type' => 'checkbox', + '#title' => t('Remove'), + '#prefix' => '<div class="feeds-ui-checkbox-link">', + '#suffix' => '</div>', + ); + + $form['mapping_weight'][$i] = array( + '#type' => 'weight', + '#title' => '', + '#default_value' => $i, + '#delta' => $delta, + '#attributes' => array( + 'class' => array( + 'feeds-ui-mapping-weight' + ), + ), + ); + } + } + + if (isset($source_options)) { + $form['source'] = array( + '#type' => 'select', + '#options' => array('' => t('Select a source')) + $source_options, + ); + } + else { + $form['source'] = array( + '#type' => 'textfield', + '#size' => 20, + '#default_value' => t('Name of source field'), + '#attributes' => array('class' => array('hide-text-on-focus')), + ); + } + $form['target'] = array( + '#type' => 'select', + '#options' => array('' => t('Select a target')) + $target_options, + ); + $form['add'] = array( + '#type' => 'submit', + '#value' => t('Add'), + '#submit' => array('feeds_ui_mapping_form_add_submit'), + '#validate' => array('feeds_ui_mapping_form_add_validate'), + ); + $form['save'] = array( + '#type' => 'submit', + '#value' => t('Save'), + '#attributes' => array('class' => array('feeds-ui-hidden-submit')), + ); + return $form; +} + +/** + * Per mapper configuration form that is a part of feeds_ui_mapping_form(). + */ +function feeds_ui_mapping_settings_form($form, $form_state, $i, $mapping, $target) { + $form_state += array( + 'mapping_settings_edit' => NULL, + 'mapping_settings' => array(), + ); + + $base_button = array( + '#submit' => array('feeds_ui_mapping_form_multistep_submit'), + '#ajax' => array( + 'callback' => 'feeds_ui_mapping_settings_form_callback', + 'wrapper' => 'feeds-ui-mapping-form-wrapper', + 'effect' => 'fade', + 'progress' => 'none', + ), + '#i' => $i, + ); + + if (isset($form_state['mapping_settings'][$i])) { + $mapping = $form_state['mapping_settings'][$i] + $mapping; + } + + if ($form_state['mapping_settings_edit'] === $i) { + // Build the form. + if (isset($target['form_callback'])) { + $settings_form = call_user_func($target['form_callback'], $mapping, $target, $form, $form_state); + } + else { + $settings_form = array(); + } + + // Merge in the optional unique form. + $settings_form += feeds_ui_mapping_settings_optional_unique_form($mapping, $target, $form, $form_state); + + return array( + '#type' => 'container', + 'settings' => $settings_form, + 'save_settings' => $base_button + array( + '#type' => 'submit', + '#name' => 'mapping_settings_update_' . $i, + '#value' => t('Update'), + '#op' => 'update', + ), + 'cancel_settings' => $base_button + array( + '#type' => 'submit', + '#name' => 'mapping_settings_cancel_' . $i, + '#value' => t('Cancel'), + '#op' => 'cancel', + ), + ); + } + else { + // Build the summary. + if (isset($target['summary_callback'])) { + $summary = call_user_func($target['summary_callback'], $mapping, $target, $form, $form_state); + } + else { + $summary = ''; + } + + // Append the optional unique summary. + if ($optional_unique_summary = feeds_ui_mapping_settings_optional_unique_summary($mapping, $target, $form, $form_state)) { + $summary .= ' ' . $optional_unique_summary; + } + + if ($summary) { + return array( + 'summary' => array( + '#prefix' => '<div>', + '#markup' => $summary, + '#suffix' => '</div>', + ), + 'edit_settings' => $base_button + array( + '#type' => 'image_button', + '#name' => 'mapping_settings_edit_' . $i, + '#src' => 'misc/configure.png', + '#attributes' => array('alt' => t('Edit')), + '#op' => 'edit', + ), + ); + } + } +} + +/** + * Submit callback for a per mapper configuration form. Switches between edit + * and summary mode. + */ +function feeds_ui_mapping_form_multistep_submit($form, &$form_state) { + $trigger = $form_state['triggering_element']; + + switch ($trigger['#op']) { + case 'edit': + $form_state['mapping_settings_edit'] = $trigger['#i']; + break; + + case 'update': + $values = $form_state['values']['config'][$trigger['#i']]['settings']; + $form_state['mapping_settings'][$trigger['#i']] = $values; + unset($form_state['mapping_settings_edit']); + break; + + case 'cancel': + unset($form_state['mapping_settings_edit']); + break; + } + + $form_state['rebuild'] = TRUE; +} + +/** + * AJAX callback that returns the whole feeds_ui_mapping_form(). + */ +function feeds_ui_mapping_settings_form_callback($form, $form_state) { + return $form; +} + +/** + * Validation for source and target selection. + */ +function feeds_ui_mapping_form_add_validate($form, &$form_state) { + if ($form_state['values']['source'] == '') { + form_set_error('source', t('You must select a mapping source.')); + } + if ($form_state['values']['target'] == '') { + form_set_error('target', t('You must select a mapping target.')); + } +} + +/** + * Submit handler for add button on feeds_ui_mapping_form(). + */ +function feeds_ui_mapping_form_add_submit($form, &$form_state) { + $importer = feeds_importer($form['#importer']); + try { + $mappings = $form['#mappings']; + $mappings[] = array( + 'source' => $form_state['values']['source'], + 'target' => $form_state['values']['target'], + 'unique' => FALSE, + ); + $importer->processor->addConfig(array('mappings' => $mappings)); + $importer->save(); + drupal_set_message(t('Mapping has been added.')); + } + catch (Exception $e) { + drupal_set_message($e->getMessage(), 'error'); + } +} + +/** + * Submit handler for save button on feeds_ui_mapping_form(). + */ +function feeds_ui_mapping_form_submit($form, &$form_state) { + $importer = feeds_importer($form['#importer']); + $processor = $importer->processor; + + $form_state += array( + 'mapping_settings' => array(), + 'mapping_settings_edit' => NULL, + ); + + // If an item is in edit mode, prepare it for saving. + if ($form_state['mapping_settings_edit'] !== NULL) { + $values = $form_state['values']['config'][$form_state['mapping_settings_edit']]['settings']; + $form_state['mapping_settings'][$form_state['mapping_settings_edit']] = $values; + } + + // We may set some settings to mappings that we remove in the subsequent step, + // that's fine. + $mappings = $form['#mappings']; + foreach ($form_state['mapping_settings'] as $k => $v) { + $mappings[$k] = array( + 'source' => $mappings[$k]['source'], + 'target' => $mappings[$k]['target'], + ) + $v; + } + + if (!empty($form_state['values']['remove_flags'])) { + $remove_flags = array_keys(array_filter($form_state['values']['remove_flags'])); + + foreach ($remove_flags as $k) { + unset($mappings[$k]); + unset($form_state['values']['mapping_weight'][$k]); + } + } + + // Keep our keys clean. + $mappings = array_values($mappings); + + if (!empty($mappings)) { + array_multisort($form_state['values']['mapping_weight'], $mappings); + } + + $processor->addConfig(array('mappings' => $mappings)); + $importer->save(); + drupal_set_message(t('Your changes have been saved.')); +} + +/** + * Walk the result of FeedsParser::getMappingSources() or + * FeedsProcessor::getMappingTargets() and format them into + * a Form API options array. + */ +function _feeds_ui_format_options($options) { + $result = array(); + foreach ($options as $k => $v) { + if (is_array($v) && !empty($v['name'])) { + $result[$k] = $v['name']; + } + elseif (is_array($v)) { + $result[$k] = $k; + } + else { + $result[$k] = $v; + } + } + asort($result); + return $result; +} + +/** + * Per mapping settings summary callback. Shows whether a mapping is used as + * unique or not. + */ +function feeds_ui_mapping_settings_optional_unique_summary($mapping, $target, $form, $form_state) { + if (!empty($target['optional_unique'])) { + if ($mapping['unique']) { + return t('Used as <strong>unique</strong>.'); + } + else { + return t('Not used as unique.'); + } + } +} + +/** + * Per mapping settings form callback. Lets the user choose if a target is as + * unique or not. + */ +function feeds_ui_mapping_settings_optional_unique_form($mapping, $target, $form, $form_state) { + $settings_form = array(); + + if (!empty($target['optional_unique'])) { + $settings_form['unique'] = array( + '#type' => 'checkbox', + '#title' => t('Unique'), + '#default_value' => !empty($mapping['unique']), + ); + } + + return $settings_form; +} + +/** + * Theme feeds_ui_overview_form(). + */ +function theme_feeds_ui_overview_form($variables) { + $form = $variables['form']; + drupal_add_js(drupal_get_path('module', 'feeds_ui') . '/feeds_ui.js'); + drupal_add_css(drupal_get_path('module', 'feeds_ui') . '/feeds_ui.css'); + + // Iterate through all importers and build a table. + $rows = array(); + foreach (array('enabled', 'disabled') as $type) { + if (isset($form[$type])) { + foreach (element_children($form[$type]) as $id) { + $row = array(); + foreach (element_children($form[$type][$id]) as $col) { + $row[$col] = array( + 'data' => drupal_render($form[$type][$id][$col]), + 'class' => array($type), + ); + } + $rows[] = array( + 'data' => $row, + 'class' => array($type), + ); + } + } + } + + $output = theme('table', array( + 'header' => $form['#header'], + 'rows' => $rows, + 'attributes' => array('class' => array('feeds-admin-importers')), + 'empty' => t('No importers available.'), + )); + + if (!empty($rows)) { + $output .= drupal_render_children($form); + } + + return $output; +} + +/** + * Theme feeds_ui_edit_page(). + */ +function theme_feeds_ui_edit_page($variables) { + $config_info = $variables['info']; + $active_container = $variables['active']; + drupal_add_css(drupal_get_path('module', 'feeds_ui') . '/feeds_ui.css'); + + // Outer wrapper. + $output = '<div class="feeds-settings clear-block">'; + + // Build left bar. + $output .= '<div class="left-bar">'; + foreach ($config_info as $info) { + $output .= theme('feeds_ui_container', array('container' => $info)); + } + $output .= '</div>'; + + // Build configuration space. + $output .= '<div class="configuration">'; + $output .= '<div class="configuration-squeeze">'; + $output .= theme('feeds_ui_container', array('container' => $active_container)); + $output .= '</div>'; + $output .= '</div>'; + + $output .= '</div>'; // ''<div class="feeds-settings">'; + + return $output; +} + +/** + * Render a simple container. A container can have a title, a description and + * one or more actions. Recursive. + * + * @todo Replace with theme_fieldset or a wrapper to theme_fieldset? + * + * @param $variables + * An array containing an array at 'container'. + * A 'container' array may contain one or more of the following keys: + * array( + * 'title' => 'the title', + * 'body' => 'the body of the container, may also be an array of more + * containers or a renderable array.', + * 'class' => array('the class of the container.'), + * 'id' => 'the id of the container', + * ); + */ +function theme_feeds_ui_container($variables) { + $container = $variables['container']; + + $class = array_merge(array('feeds-container'), empty($container['class']) ? array('plain') : $container['class']); + $id = empty($container['id']) ? '': ' id="' . $container['id'] . '"'; + $output = '<div class="' . implode(' ', $class) . '"' . $id . '>'; + + if (isset($container['actions']) && count($container['actions'])) { + $output .= '<ul class="container-actions">'; + foreach ($container['actions'] as $action) { + $output .= '<li>' . $action . '</li>'; + } + $output .= '</ul>'; + } + + if (!empty($container['title'])) { + $output .= '<h4 class="feeds-container-title">'; + $output .= $container['title']; + $output .= '</h4>'; + } + + if (!empty($container['body'])) { + $output .= '<div class="feeds-container-body">'; + if (is_array($container['body'])) { + if (isset($container['body']['#type'])) { + $output .= drupal_render($container['body']); + } + else { + foreach ($container['body'] as $c) { + $output .= theme('feeds_ui_container', array('container' => $c)); + } + } + } + else { + $output .= $container['body']; + } + $output .= '</div>'; + } + + $output .= '</div>'; + return $output; +} + +/** + * Theme function for feeds_ui_mapping_form(). + */ +function theme_feeds_ui_mapping_form($variables) { + $form = $variables['form']; + + // Build the actual mapping table. + $header = array( + t('Source'), + t('Target'), + t('Target configuration'), + ' ', + t('Weight'), + ); + $rows = array(); + if (is_array($form['#mappings'])) { + foreach ($form['#mappings'] as $i => $mapping) { + // Some parsers do not define source options. + $source = isset($form['source']['#options'][$mapping['source']]) ? $form['source']['#options'][$mapping['source']] : $mapping['source']; + $target = isset($form['target']['#options'][$mapping['target']]) ? check_plain($form['target']['#options'][$mapping['target']]) : '<em>' . t('Missing') . '</em>'; + $rows[] = array( + 'data' => array( + check_plain($source), + $target, + drupal_render($form['config'][$i]), + drupal_render($form['remove_flags'][$i]), + drupal_render($form['mapping_weight'][$i]), + ), + 'class' => array('draggable', 'tabledrag-leaf'), + ); + } + } + if (!count($rows)) { + $rows[] = array( + array( + 'colspan' => 5, + 'data' => t('No mappings defined.'), + ), + ); + } + $rows[] = array( + drupal_render($form['source']), + drupal_render($form['target']), + '', + drupal_render($form['add']), + '', + ); + $output = '<div class="help feeds-admin-ui">' . drupal_render($form['help']) . '</div>'; + $output .= theme('table', array('header' => $header, 'rows' => $rows, 'attributes' => array('id' => 'feeds-ui-mapping-overview'))); + + // Build the help table that explains available sources. + $legend = ''; + $rows = array(); + foreach (element_children($form['legendset']['legend']['sources']) as $k) { + $rows[] = array( + check_plain(drupal_render($form['legendset']['legend']['sources'][$k]['name'])), + check_plain(drupal_render($form['legendset']['legend']['sources'][$k]['description'])), + ); + } + if (count($rows)) { + $legend .= '<h4>' . t('Sources') . '</h4>'; + $legend .= theme('table', array('header' => array(t('Name'), t('Description')), 'rows' => $rows)); + } + + // Build the help table that explains available targets. + $rows = array(); + foreach (element_children($form['legendset']['legend']['targets']) as $k) { + $rows[] = array( + check_plain(drupal_render($form['legendset']['legend']['targets'][$k]['name'])), + check_plain(drupal_render($form['legendset']['legend']['targets'][$k]['description'])), + ); + } + $legend .= '<h4>' . t('Targets') . '</h4>'; + $legend .= theme('table', array('header' => array(t('Name'), t('Description')), 'rows' => $rows)); + + // Stick tables into collapsible fieldset. + $form['legendset']['legend'] = array( + '#markup' => '<div>' . $legend . '</div>', + ); + + $output .= drupal_render($form['legendset']); + $output .= drupal_render_children($form); + + drupal_add_tabledrag('feeds-ui-mapping-overview', 'order', 'sibling', 'feeds-ui-mapping-weight'); + return $output; +} diff --git a/sites/all/modules/feeds/feeds_ui/feeds_ui.css b/sites/all/modules/feeds/feeds_ui/feeds_ui.css new file mode 100644 index 0000000000000000000000000000000000000000..b1b9e4cb420e734fd8de6d687a1ecd3387db6e18 --- /dev/null +++ b/sites/all/modules/feeds/feeds_ui/feeds_ui.css @@ -0,0 +1,90 @@ + +/* Feeds admin overview form. */ +table.feeds-admin-importers thead th { + border: none; + } +table.feeds-admin-importers td.disabled { + color: #aaa; + } +table.feeds-admin-importers tr.disabled.odd, +table.feeds-admin-importers tr.disabled.even { + border-color: #eee; + } +table.feeds-admin-importers tr.disabled.odd { + background-color: #f5f5f5; + } + +/* Feeds edit form layout. */ +div.feeds-settings { + } +div.left-bar { + float: left; + position: relative; + width: 240px; + border-right: 1px solid #DDD; + padding: 10px 10px 0 0; + } +div.configuration { + padding: 10px 0 0 250px; + margin-left: -240px; + } + div.configuration-squeeze { + margin-left: 250px; + } + +/* Container theming. */ +div.feeds-container { + } +div.feeds-container h4 { + font-size: 1.2em; + font-weight: bold; + } +div.feeds-container.plain { + background-color: #EEE; + border-bottom: 1px solid #DDD; + border-top: 2px solid #DDD; + padding: 5px; + margin: 10px 0; + } + div.feeds-container.plain h4 { + font-size: 1.0em; + margin: 0; + padding: 0; + } + div.feeds-container-body p { + padding: 5px 0; + margin: 0; + } + div.feeds-container-body div.item-list ul { + margin: 0; + } + div.feeds-container-body div.item-list ul li { + list-style-type: none; + margin: 0; + padding: 0; + background-image: none; + } + ul.container-actions { + font-family: Arial, Helvetica; + float: right; + margin: 0; + } + ul.container-actions li { + list-style-type: none; + text-align: right; + background-image: none; + margin: 0; + padding: 0; + } + ul.container-actions .form-item, + ul.container-actions li form, + ul.container-actions li form input { + padding: 0; + margin: 0; + display: inline; + } + +/* Mapping form. */ +#center table form { + margin: 0; + } diff --git a/sites/all/modules/feeds/feeds_ui/feeds_ui.info b/sites/all/modules/feeds/feeds_ui/feeds_ui.info new file mode 100644 index 0000000000000000000000000000000000000000..0a2e4fd4f9f6c6479f471ca728ad5a0255cbd583 --- /dev/null +++ b/sites/all/modules/feeds/feeds_ui/feeds_ui.info @@ -0,0 +1,15 @@ +name = Feeds Admin UI +description = Administrative UI for Feeds module. +package = Feeds +core = 7.x +dependencies[] = feeds +configure = admin/structure/feeds + +files[] = feeds_ui.test + +; Information added by drupal.org packaging script on 2012-10-24 +version = "7.x-2.0-alpha7" +core = "7.x" +project = "feeds" +datestamp = "1351111319" + diff --git a/sites/all/modules/feeds/feeds_ui/feeds_ui.install b/sites/all/modules/feeds/feeds_ui/feeds_ui.install new file mode 100644 index 0000000000000000000000000000000000000000..bbe11f34707838d97c71a77514bd2a5db8b43c68 --- /dev/null +++ b/sites/all/modules/feeds/feeds_ui/feeds_ui.install @@ -0,0 +1,13 @@ +<?php + +/** + * @file + * Install, uninstall, and update functions for the feeds_ui module. + */ + +/** + * Empty update function to trigger a menu rebuild. + */ +function feeds_ui_update_7000() { + // Do nothing. +} diff --git a/sites/all/modules/feeds/feeds_ui/feeds_ui.js b/sites/all/modules/feeds/feeds_ui/feeds_ui.js new file mode 100644 index 0000000000000000000000000000000000000000..f5e8b7b1a889084a76cd8a65722721cfb083c9a0 --- /dev/null +++ b/sites/all/modules/feeds/feeds_ui/feeds_ui.js @@ -0,0 +1,62 @@ + +Drupal.behaviors.feeds = function() { + + // Hide text in specific input fields. + $('.hide-text-on-focus').focus(function() { + $(this).val(''); + }); + + + // Hide submit buttons of .feeds-ui-hidden-submit class. + $('input.form-submit.feeds-ui-hidden-submit').hide(); + + /** + * Tune checkboxes on mapping forms. + * @see feeds_ui_mapping_form() in feeds_ui.admin.inc + */ + + // Attach submit behavior to elements with feeds-ui-trigger-submit class. + $('.feeds-ui-trigger-submit').click(function() { + // Use click, not form.submit() - submit() would use the wrong submission + // handler. + $('input.form-submit.feeds-ui-hidden-submit').click(); + }); + + // Replace checkbox with .feeds-ui-checkbox-link class with a link. + $('.feeds-ui-checkbox-link:not(.processed)').each(function(i) { + $(this).addClass('processed').after( + '<a href="#" onclick="return false;" class="feeds-ui-trigger-remove">' + $('label', this).text() + '</a>' + ).hide(); + }); + + // Check the box and then submit. + $('.feeds-ui-trigger-remove').click(function() { + // Use click, not form.submit() - submit() would use the wrong submission + // handler. + $(this).prev().children().children().children().attr('checked', 1); + $('input.form-submit.feeds-ui-hidden-submit').click(); + }); + + // Replace radio with .feeds-ui-radio-link class with a link. + $('.feeds-ui-radio-link:not(.processed)').parent().each(function(i) { + checked = ''; + if ($(this).children('input').attr('checked')) { + checked = ' checked'; + } + $(this).addClass('processed').after( + '<a href="#" onclick="return false;" class="feeds-ui-check-submit' + checked + '" id="' + $(this).children('input').attr('id') + '">' + $(this).parent().text() + '</a>' + ); + $(this).hide(); + }); + + // Hide the the radio that is selected. + $('.feeds-ui-check-submit.checked').parent().hide(); + + // Check the radio and then submit. + $('.feeds-ui-check-submit').click(function() { + // Use click, not form.submit() - submit() would use the wrong submission + // handler. + $('#' + $(this).attr('id')).attr('checked', 1); + $('input.form-submit.feeds-ui-hidden-submit').click(); + }); +}; diff --git a/sites/all/modules/feeds/feeds_ui/feeds_ui.module b/sites/all/modules/feeds/feeds_ui/feeds_ui.module new file mode 100644 index 0000000000000000000000000000000000000000..ba76d6fcef0140d6ad61d4a444f705125b2639fd --- /dev/null +++ b/sites/all/modules/feeds/feeds_ui/feeds_ui.module @@ -0,0 +1,137 @@ +<?php +/** + * @file + */ + +/** + * Implements hook_help(). + */ +function feeds_ui_help($path, $arg) { + switch ($path) { + case 'admin/structure/feeds': + $output = '<p>' . t('Create one or more Feed importers for pulling content into Drupal. You can use these importers from the <a href="@import">Import</a> page or - if you attach them to a content type - simply by creating a node from that content type.', array('@import' => url('import'))) . '</p>'; + return $output; + } +} + +/** + * Implements hook_menu(). + */ +function feeds_ui_menu() { + $items = array(); + $items['admin/structure/feeds'] = array( + 'title' => 'Feeds importers', + 'description' => 'Configure one or more Feeds importers to aggregate RSS and Atom feeds, import CSV files or more.', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('feeds_ui_overview_form'), + 'access arguments' => array('administer feeds'), + 'file' => 'feeds_ui.admin.inc', + ); + $items['admin/structure/feeds/create'] = array( + 'title' => 'Add importer', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('feeds_ui_create_form'), + 'access arguments' => array('administer feeds'), + 'file' => 'feeds_ui.admin.inc', + 'type' => MENU_LOCAL_ACTION, + ); + $items['admin/structure/feeds/%feeds_importer'] = array( + 'title callback' => 'feeds_ui_importer_title', + 'title arguments' => array(3), + 'page callback' => 'feeds_ui_edit_page', + 'page arguments' => array(3), + 'access arguments' => array('administer feeds'), + 'file' => 'feeds_ui.admin.inc', + ); + $items['admin/structure/feeds/%feeds_importer/edit'] = array( + 'title' => 'Edit', + 'page callback' => 'feeds_ui_edit_page', + 'page arguments' => array(3), + 'access arguments' => array('administer feeds'), + 'file' => 'feeds_ui.admin.inc', + 'type' => MENU_DEFAULT_LOCAL_TASK, + 'weight' => 1, + ); + $items['admin/structure/feeds/%feeds_importer/export'] = array( + 'title' => 'Export', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('feeds_ui_export_form', 3), + 'access arguments' => array('administer feeds'), + 'file' => 'feeds_ui.admin.inc', + 'type' => MENU_LOCAL_TASK, + 'weight' => 2, + ); + $items['admin/structure/feeds/%feeds_importer/clone'] = array( + 'title' => 'Clone', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('feeds_ui_create_form', 3), + 'access arguments' => array('administer feeds'), + 'file' => 'feeds_ui.admin.inc', + 'type' => MENU_LOCAL_TASK, + 'weight' => 3, + ); + $items['admin/structure/feeds/%feeds_importer/delete'] = array( + 'title' => 'Delete', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('feeds_ui_delete_form', 3), + 'access arguments' => array('administer feeds'), + 'file' => 'feeds_ui.admin.inc', + 'type' => MENU_LOCAL_TASK, + 'weight' => 4, + ); + return $items; +} + +/** + * Implements hook_theme(). + */ +function feeds_ui_theme() { + return array( + 'feeds_ui_overview_form' => array( + 'render element' => 'form', + 'file' => 'feeds_ui.admin.inc', + ), + 'feeds_ui_mapping_form' => array( + 'render element' => 'form', + 'file' => 'feeds_ui.admin.inc', + ), + 'feeds_ui_edit_page' => array( + 'variables' => array('info' => NULL, 'active' => NULL), + 'file' => 'feeds_ui.admin.inc', + ), + 'feeds_ui_plugin_form' => array( + 'render element' => 'form', + 'file' => 'feeds_ui.admin.inc', + ), + 'feeds_ui_container' => array( + 'variables' => array('container' => NULL), + 'file' => 'feeds_ui.admin.inc', + ), + ); +} + +/** + * Implements hook_admin_menu_map(). + */ +function feeds_ui_admin_menu_map() { + // Add awareness to the administration menu of the various importers so they + // are included in the dropdown menu. + if (!user_access('administer feeds')) { + return; + } + $map['admin/structure/feeds/%feeds_importer'] = array( + 'parent' => 'admin/structure/feeds', + 'arguments' => array( + array('%feeds_importer' => feeds_enabled_importers()), + ), + ); + + return $map; +} + +/** + * Title callback for importers. + */ +function feeds_ui_importer_title($importer) { + return t('@importer', array('@importer' => $importer->config['name'])); +} diff --git a/sites/all/modules/feeds/feeds_ui/feeds_ui.test b/sites/all/modules/feeds/feeds_ui/feeds_ui.test new file mode 100644 index 0000000000000000000000000000000000000000..39da76588d21054bc21cefdc1804a93585acd43e --- /dev/null +++ b/sites/all/modules/feeds/feeds_ui/feeds_ui.test @@ -0,0 +1,117 @@ +<?php + +/** + * @file + * Tests for Feeds Admin UI module. + */ + +/** + * Test basic Feeds UI functionality. + */ +class FeedsUIUserInterfaceTestCase extends FeedsWebTestCase { + public static function getInfo() { + return array( + 'name' => 'Feeds UI user interface', + 'description' => 'Tests Feeds Admin UI module\'s GUI.', + 'group' => 'Feeds', + ); + } + + /** + * UI functionality tests on + * feeds_ui_overview(), + * feeds_ui_create_form(), + * Change plugins on feeds_ui_edit_page(). + */ + public function testEditFeedConfiguration() { + + // Create an importer. + $this->createImporterConfiguration('Test feed', 'test_feed'); + + // Assert UI elements. + $this->drupalGet('admin/structure/feeds/test_feed'); + $this->assertText('Basic settings'); + $this->assertText('Fetcher'); + $this->assertText('HTTP Fetcher'); + $this->assertText('Parser'); + $this->assertText('Common syndication parser'); + $this->assertText('Processor'); + $this->assertText('Node processor'); + $this->assertText('Getting started'); + $this->assertRaw('admin/structure/feeds/test_feed/settings'); + $this->assertRaw('admin/structure/feeds/test_feed/settings/FeedsNodeProcessor'); + $this->assertRaw('admin/structure/feeds/test_feed/fetcher'); + $this->assertRaw('admin/structure/feeds/test_feed/parser'); + $this->assertRaw('admin/structure/feeds/test_feed/processor'); + $this->drupalGet('import'); + $this->assertText('Basic page'); + + // Select some other plugins. + $this->drupalGet('admin/structure/feeds/test_feed'); + + $this->clickLink('Change', 0); + $this->assertText('Select a fetcher'); + $edit = array( + 'plugin_key' => 'FeedsFileFetcher', + ); + $this->drupalPost('admin/structure/feeds/test_feed/fetcher', $edit, 'Save'); + + $this->clickLink('Change', 1); + $this->assertText('Select a parser'); + $edit = array( + 'plugin_key' => 'FeedsCSVParser', + ); + $this->drupalPost('admin/structure/feeds/test_feed/parser', $edit, 'Save'); + + $this->clickLink('Change', 2); + $this->assertText('Select a processor'); + $edit = array( + 'plugin_key' => 'FeedsUserProcessor', + ); + $this->drupalPost('admin/structure/feeds/test_feed/processor', $edit, 'Save'); + + // Assert changed configuration. + $this->assertPlugins('test_feed', 'FeedsFileFetcher', 'FeedsCSVParser', 'FeedsUserProcessor'); + + // Delete importer. + $this->drupalPost('admin/structure/feeds/test_feed/delete', array(), 'Delete'); + $this->drupalGet('import'); + $this->assertNoText('Test feed'); + + // Create the same importer again. + $this->createImporterConfiguration('Test feed', 'test_feed'); + + // Test basic settings settings. + $edit = array( + 'name' => 'Syndication feed', + 'content_type' => 'page', + 'import_period' => 3600, + ); + $this->drupalPost('admin/structure/feeds/test_feed/settings', $edit, 'Save'); + + // Assert results of change. + $this->assertText('Syndication feed'); + $this->assertText('Your changes have been saved.'); + $this->assertText('Attached to: Basic page'); + $this->assertText('Periodic import: every 1 hour'); + $this->drupalGet('admin/structure/feeds'); + $this->assertLink('Basic page'); + + // Configure processor. + $edit = array( + 'content_type' => 'article', + ); + $this->drupalPost('admin/structure/feeds/test_feed/settings/FeedsNodeProcessor', $edit, 'Save'); + $this->assertFieldByName('content_type', 'article'); + + // Create a feed node. + $edit = array( + 'title' => 'Development Seed', + 'feeds[FeedsHTTPFetcher][source]' => $GLOBALS['base_url'] . '/' . drupal_get_path('module', 'feeds') . '/tests/feeds/developmentseed.rss2', + ); + $this->drupalPost('node/add/page', $edit, 'Save'); + $this->assertText('Basic page Development Seed has been created.'); + + // @todo Refreshing/deleting feed items. Needs to live in feeds.test + } +} diff --git a/sites/all/modules/feeds/includes/FeedsConfigurable.inc b/sites/all/modules/feeds/includes/FeedsConfigurable.inc new file mode 100644 index 0000000000000000000000000000000000000000..0f3b013c65a3874ee98482a8e803ed0493c690a1 --- /dev/null +++ b/sites/all/modules/feeds/includes/FeedsConfigurable.inc @@ -0,0 +1,291 @@ +<?php + +/** + * @file + * FeedsConfigurable and helper functions. + */ + +/** + * Used when an object does not exist in the DB or code but should. + */ +class FeedsNotExistingException extends Exception { +} + +/** + * Base class for configurable classes. Captures configuration handling, form + * handling and distinguishes between in-memory configuration and persistent + * configuration. + */ +abstract class FeedsConfigurable { + + // Holds the actual configuration information. + protected $config; + + // A unique identifier for the configuration. + protected $id; + + /* + CTools export type of this object. + + @todo Should live in FeedsImporter. Not all child classes + of FeedsConfigurable are exportable. Same goes for $disabled. + + Export type can be one of + FEEDS_EXPORT_NONE - the configurable only exists in memory + EXPORT_IN_DATABASE - the configurable is defined in the database. + EXPORT_IN_CODE - the configurable is defined in code. + EXPORT_IN_CODE | EXPORT_IN_DATABASE - the configurable is defined in code, but + overridden in the database.*/ + protected $export_type; + + /** + * CTools export enabled status of this object. + */ + protected $disabled; + + /** + * Instantiate a FeedsConfigurable object. + * + * Don't use directly, use feeds_importer() or feeds_plugin() + * instead. + */ + public static function instance($class, $id) { + // This is useful at least as long as we're developing. + if (empty($id)) { + throw new Exception(t('Empty configuration identifier.')); + } + static $instances = array(); + if (!isset($instances[$class][$id])) { + $instances[$class][$id] = new $class($id); + } + return $instances[$class][$id]; + } + + /** + * Constructor, set id and load default configuration. + */ + protected function __construct($id) { + // Set this object's id. + $this->id = $id; + // Per default we assume that a Feeds object is not saved to + // database nor is it exported to code. + $this->export_type = FEEDS_EXPORT_NONE; + // Make sure configuration is populated. + $this->config = $this->configDefaults(); + $this->disabled = FALSE; + } + + /** + * Override magic method __isset(). This is needed due to overriding __get(). + */ + public function __isset($name) { + return isset($this->$name) ? TRUE : FALSE; + } + + /** + * Determine whether this object is persistent and enabled. I. e. it is + * defined either in code or in the database and it is enabled. + */ + public function existing() { + if ($this->export_type == FEEDS_EXPORT_NONE) { + throw new FeedsNotExistingException(t('Object is not persistent.')); + } + if ($this->disabled) { + throw new FeedsNotExistingException(t('Object is disabled.')); + } + return $this; + } + + /** + * Save a configuration. Concrete extending classes must implement a save + * operation. + */ + public abstract function save(); + + /** + * Copy a configuration. + */ + public function copy(FeedsConfigurable $configurable) { + $this->setConfig($configurable->config); + } + + /** + * Set configuration. + * + * @param $config + * Array containing configuration information. Config array will be filtered + * by the keys returned by configDefaults() and populated with default + * values that are not included in $config. + */ + public function setConfig($config) { + $defaults = $this->configDefaults(); + $this->config = array_intersect_key($config, $defaults) + $defaults; + } + + /** + * Similar to setConfig but adds to existing configuration. + * + * @param $config + * Array containing configuration information. Will be filtered by the keys + * returned by configDefaults(). + */ + public function addConfig($config) { + $this->config = is_array($this->config) ? array_merge($this->config, $config) : $config; + $default_keys = $this->configDefaults(); + $this->config = array_intersect_key($this->config, $default_keys); + } + + /** + * Override magic method __get(). Make sure that $this->config goes through + * getConfig(). + */ + public function __get($name) { + if ($name == 'config') { + return $this->getConfig(); + } + return isset($this->$name) ? $this->$name : NULL; + } + + /** + * Implements getConfig(). + * + * Return configuration array, ensure that all default values are present. + */ + public function getConfig() { + $defaults = $this->configDefaults(); + return $this->config + $defaults; + } + + /** + * Return default configuration. + * + * @todo rename to getConfigDefaults(). + * + * @return + * Array where keys are the variable names of the configuration elements and + * values are their default values. + */ + public function configDefaults() { + return array(); + } + + /** + * Return configuration form for this object. The keys of the configuration + * form must match the keys of the array returned by configDefaults(). + * + * @return + * FormAPI style form definition. + */ + public function configForm(&$form_state) { + return array(); + } + + /** + * Validation handler for configForm(). + * + * Set errors with form_set_error(). + * + * @param $values + * An array that contains the values entered by the user through configForm. + */ + public function configFormValidate(&$values) { + } + + /** + * Submission handler for configForm(). + * + * @param $values + */ + public function configFormSubmit(&$values) { + $this->addConfig($values); + $this->save(); + drupal_set_message(t('Your changes have been saved.')); + feeds_cache_clear(FALSE); + } +} + +/** + * Config form wrapper. Use to render the configuration form of + * a FeedsConfigurable object. + * + * @param $configurable + * FeedsConfigurable object. + * @param $form_method + * The form method that should be rendered. + * + * @return + * Config form array if available. NULL otherwise. + */ +function feeds_get_form($configurable, $form_method) { + if (method_exists($configurable, $form_method)) { + return drupal_get_form(get_class($configurable) . '_feeds_form', $configurable, $form_method); + } +} + +/** + * Config form callback. Don't call directly, but use + * feeds_get_form($configurable, 'method') instead. + * + * @param + * FormAPI $form_state. + * @param + * FeedsConfigurable object. + * @param + * The object to perform the save() operation on. + * @param $form_method + * The $form_method that should be rendered. + */ +function feeds_form($form, &$form_state, $configurable, $form_method) { + $form = $configurable->$form_method($form_state); + $form['#configurable'] = $configurable; + $form['#feeds_form_method'] = $form_method; + $form['#validate'] = array('feeds_form_validate'); + $form['#submit'] = array('feeds_form_submit'); + $form['submit'] = array( + '#type' => 'submit', + '#value' => t('Save'), + '#weight' => 100, + ); + return $form; +} + +/** + * Validation handler for feeds_form(). + */ +function feeds_form_validate($form, &$form_state) { + _feeds_form_helper($form, $form_state, 'Validate'); +} + +/** + * Submit handler for feeds_form(). + */ +function feeds_form_submit($form, &$form_state) { + _feeds_form_helper($form, $form_state, 'Submit'); +} + +/** + * Helper for Feeds validate and submit callbacks. + */ +function _feeds_form_helper($form, &$form_state, $action) { + $method = $form['#feeds_form_method'] . $action; + $class = get_class($form['#configurable']); + $id = $form['#configurable']->id; + + // Re-initialize the configurable object. Using feeds_importer() and + // feeds_plugin() will ensure that we're using the same instance. We can't + // reuse the previous form instance because feeds_importer() is used to save. + // This will re-initialize all of the plugins anyway, causing some tricky + // saving issues in certain cases. + // See http://drupal.org/node/1672880. + + if ($class == variable_get('feeds_importer_class', 'FeedsImporter')) { + $form['#configurable'] = feeds_importer($id); + } + else { + $form['#configurable'] = feeds_plugin($class, $id); + } + + if (method_exists($form['#configurable'], $method)) { + $form['#configurable']->$method($form_state['values']); + } +} diff --git a/sites/all/modules/feeds/includes/FeedsImporter.inc b/sites/all/modules/feeds/includes/FeedsImporter.inc new file mode 100644 index 0000000000000000000000000000000000000000..011a7de980f1b4142ed1d0883d527939388e3b60 --- /dev/null +++ b/sites/all/modules/feeds/includes/FeedsImporter.inc @@ -0,0 +1,333 @@ +<?php + +/** + * @file + * FeedsImporter class and related. + */ + +/** + * A FeedsImporter object describes how an external source should be fetched, + * parsed and processed. Feeds can manage an arbitrary amount of importers. + * + * A FeedsImporter holds a pointer to a FeedsFetcher, a FeedsParser and a + * FeedsProcessor plugin. It further contains the configuration for itself and + * each of the three plugins. + * + * Its most important responsibilities are configuration management, interfacing + * with the job scheduler and expiring of all items produced by this + * importer. + * + * When a FeedsImporter is instantiated, it loads its configuration. Then it + * instantiates one fetcher, one parser and one processor plugin depending on + * the configuration information. After instantiating them, it sets them to + * the configuration information it holds for them. + */ +class FeedsImporter extends FeedsConfigurable { + + // Every feed has a fetcher, a parser and a processor. + // These variable names match the possible return values of + // FeedsPlugin::typeOf(). + protected $fetcher, $parser, $processor; + + // This array defines the variable names of the plugins above. + protected $plugin_types = array('fetcher', 'parser', 'processor'); + + /** + * Instantiate class variables, initialize and configure + * plugins. + */ + protected function __construct($id) { + parent::__construct($id); + + // Try to load information from database. + $this->load(); + + // Instantiate fetcher, parser and processor, set their configuration if + // stored info is available. + foreach ($this->plugin_types as $type) { + $plugin = feeds_plugin($this->config[$type]['plugin_key'], $this->id); + + if (isset($this->config[$type]['config'])) { + $plugin->setConfig($this->config[$type]['config']); + } + $this->$type = $plugin; + } + } + + /** + * Remove items older than $time. + * + * @param $time + * All items older than REQUEST_TIME - $time will be deleted. If not + * given, internal processor settings will be used. + * + * @return + * FEEDS_BATCH_COMPLETE if the expiry process finished. A decimal between + * 0.0 and 0.9 periodic if expiry is still in progress. + * + * @throws + * Throws Exception if an error occurs when expiring items. + */ + public function expire($time = NULL) { + return $this->processor->expire($time); + } + + /** + * Schedule all periodic tasks for this importer. + */ + public function schedule() { + $this->scheduleExpire(); + } + + /** + * Schedule expiry of items. + */ + public function scheduleExpire() { + $job = array( + 'type' => $this->id, + 'period' => 0, + 'periodic' => TRUE, + ); + if (FEEDS_EXPIRE_NEVER != $this->processor->expiryTime()) { + $job['period'] = 3600; + JobScheduler::get('feeds_importer_expire')->set($job); + } + else { + JobScheduler::get('feeds_importer_expire')->remove($job); + } + } + + /** + * Report how many items *should* be created on one page load by this + * importer. + * + * Note: + * + * It depends on whether parser implements batching if this limit is actually + * respected. Further, if no limit is reported it doesn't mean that the + * number of items that can be created on one page load is actually without + * limit. + * + * @return + * A positive number defining the number of items that can be created on + * one page load. 0 if this number is unlimited. + */ + public function getLimit() { + return $this->processor->getLimit(); + } + + /** + * Save configuration. + */ + public function save() { + $save = new stdClass(); + $save->id = $this->id; + $save->config = $this->getConfig(); + + if ($config = db_query("SELECT config FROM {feeds_importer} WHERE id = :id", array(':id' => $this->id))->fetchField()) { + drupal_write_record('feeds_importer', $save, 'id'); + // Only rebuild menu if content_type has changed. Don't worry about + // rebuilding menus when creating a new importer since it will default + // to the standalone page. + $config = unserialize($config); + if ($config['content_type'] != $save->config['content_type']) { + variable_set('menu_rebuild_needed', TRUE); + } + } + else { + drupal_write_record('feeds_importer', $save); + } + } + + /** + * Load configuration and unpack. + */ + public function load() { + ctools_include('export'); + if ($config = ctools_export_load_object('feeds_importer', 'conditions', array('id' => $this->id))) { + $config = array_shift($config); + $this->export_type = $config->export_type; + $this->disabled = isset($config->disabled) ? $config->disabled : FALSE; + $this->config = $config->config; + return TRUE; + } + return FALSE; + } + + /** + * Delete configuration. Removes configuration information + * from database, does not delete configuration itself. + */ + public function delete() { + db_delete('feeds_importer') + ->condition('id', $this->id) + ->execute(); + $job = array( + 'type' => $this->id, + 'id' => 0, + ); + if ($this->export_type & EXPORT_IN_CODE) { + feeds_reschedule($this->id); + } + else { + JobScheduler::get('feeds_importer_expire')->remove($job); + } + } + + /** + * Set plugin. + * + * @param $plugin_key + * A fetcher, parser or processor plugin. + * + * @todo Error handling, handle setting to the same plugin. + */ + public function setPlugin($plugin_key) { + // $plugin_type can be either 'fetcher', 'parser' or 'processor' + if ($plugin_type = FeedsPlugin::typeOf($plugin_key)) { + if ($plugin = feeds_plugin($plugin_key, $this->id)) { + // Unset existing plugin, switch to new plugin. + unset($this->$plugin_type); + $this->$plugin_type = $plugin; + // Set configuration information, blow away any previous information on + // this spot. + $this->config[$plugin_type] = array('plugin_key' => $plugin_key); + } + } + } + + /** + * Copy a FeedsImporter configuration into this importer. + * + * @param FeedsImporter $importer + * The feeds importer object to copy from. + */ + public function copy(FeedsConfigurable $configurable) { + parent::copy($configurable); + + if ($configurable instanceof FeedsImporter) { + // Instantiate new fetcher, parser and processor and initialize their + // configurations. + foreach ($this->plugin_types as $plugin_type) { + $this->setPlugin($configurable->config[$plugin_type]['plugin_key']); + $this->$plugin_type->setConfig($configurable->config[$plugin_type]['config']); + } + } + } + + /** + * Get configuration of this feed. + */ + public function getConfig() { + foreach (array('fetcher', 'parser', 'processor') as $type) { + $this->config[$type]['config'] = $this->$type->getConfig(); + } + return parent::getConfig(); + } + + /** + * Return defaults for feed configuration. + */ + public function configDefaults() { + return array( + 'name' => '', + 'description' => '', + 'fetcher' => array( + 'plugin_key' => 'FeedsHTTPFetcher', + ), + 'parser' => array( + 'plugin_key' => 'FeedsSyndicationParser', + ), + 'processor' => array( + 'plugin_key' => 'FeedsNodeProcessor', + ), + 'content_type' => '', + 'update' => 0, + 'import_period' => 1800, // Refresh every 30 minutes by default. + 'expire_period' => 3600, // Expire every hour by default, this is a hidden setting. + 'import_on_create' => TRUE, // Import on submission. + 'process_in_background' => FALSE, + ); + } + + /** + * Override parent::configForm(). + */ + public function configForm(&$form_state) { + $config = $this->getConfig(); + $form = array(); + $form['name'] = array( + '#type' => 'textfield', + '#title' => t('Name'), + '#description' => t('A human readable name of this importer.'), + '#default_value' => $config['name'], + '#required' => TRUE, + ); + $form['description'] = array( + '#type' => 'textfield', + '#title' => t('Description'), + '#description' => t('A description of this importer.'), + '#default_value' => $config['description'], + ); + $node_types = node_type_get_names(); + array_walk($node_types, 'check_plain'); + $form['content_type'] = array( + '#type' => 'select', + '#title' => t('Attach to content type'), + '#description' => t('If "Use standalone form" is selected a source is imported by using a form under !import_form. + If a content type is selected a source is imported by creating a node of that content type.', + array('!import_form' => l(url('import', array('absolute' => TRUE)), 'import', array('attributes' => array('target' => '_new'))))), + '#options' => array('' => t('Use standalone form')) + $node_types, + '#default_value' => $config['content_type'], + ); + $cron_required = ' ' . l(t('Requires cron to be configured.'), 'http://drupal.org/cron', array('attributes' => array('target' => '_new'))); + $period = drupal_map_assoc(array(900, 1800, 3600, 10800, 21600, 43200, 86400, 259200, 604800, 2419200), 'format_interval'); + foreach ($period as &$p) { + $p = t('Every !p', array('!p' => $p)); + } + $period = array( + FEEDS_SCHEDULE_NEVER => t('Off'), + 0 => t('As often as possible'), + ) + $period; + $form['import_period'] = array( + '#type' => 'select', + '#title' => t('Periodic import'), + '#options' => $period, + '#description' => t('Choose how often a source should be imported periodically.') . $cron_required, + '#default_value' => $config['import_period'], + ); + $form['import_on_create'] = array( + '#type' => 'checkbox', + '#title' => t('Import on submission'), + '#description' => t('Check if import should be started at the moment a standalone form or node form is submitted.'), + '#default_value' => $config['import_on_create'], + ); + $form['process_in_background'] = array( + '#type' => 'checkbox', + '#title' => t('Process in background'), + '#description' => t('For very large imports. If checked, import and delete tasks started from the web UI will be handled by a cron task in the background rather than by the browser. This does not affect periodic imports, they are handled by a cron task in any case.') . $cron_required, + '#default_value' => $config['process_in_background'], + ); + return $form; + } + + /** + * Reschedule if import period changes. + */ + public function configFormSubmit(&$values) { + if ($this->config['import_period'] != $values['import_period']) { + feeds_reschedule($this->id); + } + parent::configFormSubmit($values); + } +} + +/** + * Helper, see FeedsDataProcessor class. + */ +function feeds_format_expire($timestamp) { + if ($timestamp == FEEDS_EXPIRE_NEVER) { + return t('Never'); + } + return t('after !time', array('!time' => format_interval($timestamp))); +} diff --git a/sites/all/modules/feeds/includes/FeedsSource.inc b/sites/all/modules/feeds/includes/FeedsSource.inc new file mode 100644 index 0000000000000000000000000000000000000000..7133842029735e0fa464d5a0de00f0dcfd7f393f --- /dev/null +++ b/sites/all/modules/feeds/includes/FeedsSource.inc @@ -0,0 +1,725 @@ +<?php + +/** + * @file + * Definition of FeedsSourceInterface and FeedsSource class. + */ + +/** + * Distinguish exceptions occuring when handling locks. + */ +class FeedsLockException extends Exception {} + +/** + * Denote a import or clearing stage. Used for multi page processing. + */ +define('FEEDS_START', 'start_time'); +define('FEEDS_FETCH', 'fetch'); +define('FEEDS_PARSE', 'parse'); +define('FEEDS_PROCESS', 'process'); +define('FEEDS_PROCESS_CLEAR', 'process_clear'); + +/** + * Declares an interface for a class that defines default values and form + * descriptions for a FeedSource. + */ +interface FeedsSourceInterface { + + /** + * Crutch: for ease of use, we implement FeedsSourceInterface for every + * plugin, but then we need to have a handle which plugin actually implements + * a source. + * + * @see FeedsPlugin class. + * + * @return + * TRUE if a plugin handles source specific configuration, FALSE otherwise. + */ + public function hasSourceConfig(); + + /** + * Return an associative array of default values. + */ + public function sourceDefaults(); + + /** + * Return a Form API form array that defines a form configuring values. Keys + * correspond to the keys of the return value of sourceDefaults(). + */ + public function sourceForm($source_config); + + /** + * Validate user entered values submitted by sourceForm(). + */ + public function sourceFormValidate(&$source_config); + + /** + * A source is being saved. + */ + public function sourceSave(FeedsSource $source); + + /** + * A source is being deleted. + */ + public function sourceDelete(FeedsSource $source); +} + +/** + * Status of an import or clearing operation on a source. + */ +class FeedsState { + /** + * Floating point number denoting the progress made. 0.0 meaning no progress + * 1.0 = FEEDS_BATCH_COMPLETE meaning finished. + */ + public $progress; + + /** + * Used as a pointer to store where left off. Must be serializable. + */ + public $pointer; + + /** + * Natural numbers denoting more details about the progress being made. + */ + public $total; + public $created; + public $updated; + public $deleted; + public $skipped; + public $failed; + + /** + * Constructor, initialize variables. + */ + public function __construct() { + $this->progress = FEEDS_BATCH_COMPLETE; + $this->total = + $this->created = + $this->updated = + $this->deleted = + $this->skipped = + $this->failed = 0; + } + + /** + * Safely report progress. + * + * When $total == $progress, the state of the task tracked by this state is + * regarded to be complete. + * + * Handles the following cases gracefully: + * + * - $total is 0 + * - $progress is larger than $total + * - $progress approximates $total so that $finished rounds to 1.0 + * + * @param $total + * A natural number that is the total to be worked off. + * @param $progress + * A natural number that is the progress made on $total. + */ + public function progress($total, $progress) { + if ($progress > $total) { + $this->progress = FEEDS_BATCH_COMPLETE; + } + elseif ($total) { + $this->progress = $progress / $total; + if ($this->progress == FEEDS_BATCH_COMPLETE && $total != $progress) { + $this->progress = 0.99; + } + } + else { + $this->progress = FEEDS_BATCH_COMPLETE; + } + } +} + +/** + * This class encapsulates a source of a feed. It stores where the feed can be + * found and how to import it. + * + * Information on how to import a feed is encapsulated in a FeedsImporter object + * which is identified by the common id of the FeedsSource and the + * FeedsImporter. More than one FeedsSource can use the same FeedsImporter + * therefore a FeedsImporter never holds a pointer to a FeedsSource object, nor + * does it hold any other information for a particular FeedsSource object. + * + * Classes extending FeedsPlugin can implement a sourceForm to expose + * configuration for a FeedsSource object. This is for instance how FeedsFetcher + * exposes a text field for a feed URL or how FeedsCSVParser exposes a select + * field for choosing between colon or semicolon delimiters. + * + * It is important that a FeedsPlugin does not directly hold information about + * a source but leave all storage up to FeedsSource. An instance of a + * FeedsPlugin class only exists once per FeedsImporter configuration, while an + * instance of a FeedsSource class exists once per feed_nid to be imported. + * + * As with FeedsImporter, the idea with FeedsSource is that it can be used + * without actually saving the object to the database. + */ +class FeedsSource extends FeedsConfigurable { + + // Contains the node id of the feed this source info object is attached to. + // Equals 0 if not attached to any node - i. e. if used on a + // standalone import form within Feeds or by other API users. + protected $feed_nid; + + // The FeedsImporter object that this source is expected to be used with. + protected $importer; + + // A FeedsSourceState object holding the current import/clearing state of this + // source. + protected $state; + + // Fetcher result, used to cache fetcher result when batching. + protected $fetcher_result; + + // Timestamp when this source was imported the last time. + protected $imported; + + /** + * Instantiate a unique object per class/id/feed_nid. Don't use + * directly, use feeds_source() instead. + */ + public static function instance($importer_id, $feed_nid) { + $class = variable_get('feeds_source_class', 'FeedsSource'); + static $instances = array(); + if (!isset($instances[$class][$importer_id][$feed_nid])) { + $instances[$class][$importer_id][$feed_nid] = new $class($importer_id, $feed_nid); + } + return $instances[$class][$importer_id][$feed_nid]; + } + + /** + * Constructor. + */ + protected function __construct($importer_id, $feed_nid) { + $this->feed_nid = $feed_nid; + $this->importer = feeds_importer($importer_id); + parent::__construct($importer_id); + $this->load(); + } + + /** + * Returns the FeedsImporter object that this source is expected to be used with. + */ + public function importer() { + return $this->importer; + } + + /** + * Preview = fetch and parse a feed. + * + * @return + * FeedsParserResult object. + * + * @throws + * Throws Exception if an error occurs when fetching or parsing. + */ + public function preview() { + $result = $this->importer->fetcher->fetch($this); + $result = $this->importer->parser->parse($this, $result); + module_invoke_all('feeds_after_parse', $this, $result); + return $result; + } + + /** + * Start importing a source. + * + * This method starts an import job. Depending on the configuration of the + * importer of this source, a Batch API job or a background job with Job + * Scheduler will be created. + * + * @throws Exception + * If processing in background is enabled, the first batch chunk of the + * import will be executed on the current page request. This means that this + * method may throw the same exceptions as FeedsSource::import(). + */ + public function startImport() { + $config = $this->importer->getConfig(); + if ($config['process_in_background']) { + $this->startBackgroundJob('import'); + } + else { + $this->startBatchAPIJob(t('Importing'), 'import'); + } + } + + /** + * Start deleting all imported items of a source. + * + * This method starts a clear job. Depending on the configuration of the + * importer of this source, a Batch API job or a background job with Job + * Scheduler will be created. + * + * @throws Exception + * If processing in background is enabled, the first batch chunk of the + * clear task will be executed on the current page request. This means that + * this method may throw the same exceptions as FeedsSource::clear(). + */ + public function startClear() { + $config = $this->importer->getConfig(); + if ($config['process_in_background']) { + $this->startBackgroundJob('clear'); + } + else { + $this->startBatchAPIJob(t('Deleting'), 'clear'); + } + } + + /** + * Schedule all periodic tasks for this source. + */ + public function schedule() { + $this->scheduleImport(); + } + + /** + * Schedule periodic or background import tasks. + */ + public function scheduleImport() { + // Check whether any fetcher is overriding the import period. + $period = $this->importer->config['import_period']; + $fetcher_period = $this->importer->fetcher->importPeriod($this); + if (is_numeric($fetcher_period)) { + $period = $fetcher_period; + } + $period = $this->progressImporting() === FEEDS_BATCH_COMPLETE ? $period : 0; + $job = array( + 'type' => $this->id, + 'id' => $this->feed_nid, + // Schedule as soon as possible if a batch is active. + 'period' => $period, + 'periodic' => TRUE, + ); + if ($period != FEEDS_SCHEDULE_NEVER) { + JobScheduler::get('feeds_source_import')->set($job); + } + else { + JobScheduler::get('feeds_source_import')->remove($job); + } + } + + /** + * Schedule background clearing tasks. + */ + public function scheduleClear() { + $job = array( + 'type' => $this->id, + 'id' => $this->feed_nid, + 'period' => 0, + 'periodic' => TRUE, + ); + // Remove job if batch is complete. + if ($this->progressClearing() === FEEDS_BATCH_COMPLETE) { + JobScheduler::get('feeds_source_clear')->remove($job); + } + // Schedule as soon as possible if batch is not complete. + else { + JobScheduler::get('feeds_source_clear')->set($job); + } + } + + /** + * Import a source: execute fetching, parsing and processing stage. + * + * This method only executes the current batch chunk, then returns. If you are + * looking to import an entire source, use FeedsSource::startImport() instead. + * + * @return + * FEEDS_BATCH_COMPLETE if the import process finished. A decimal between + * 0.0 and 0.9 periodic if import is still in progress. + * + * @throws + * Throws Exception if an error occurs when importing. + */ + public function import() { + $this->acquireLock(); + try { + // If fetcher result is empty, we are starting a new import, log. + if (empty($this->fetcher_result)) { + $this->state[FEEDS_START] = time(); + } + + // Fetch. + if (empty($this->fetcher_result) || FEEDS_BATCH_COMPLETE == $this->progressParsing()) { + $this->fetcher_result = $this->importer->fetcher->fetch($this); + // Clean the parser's state, we are parsing an entirely new file. + unset($this->state[FEEDS_PARSE]); + } + + // Parse. + $parser_result = $this->importer->parser->parse($this, $this->fetcher_result); + module_invoke_all('feeds_after_parse', $this, $parser_result); + + // Process. + $this->importer->processor->process($this, $parser_result); + } + catch (Exception $e) { + // Do nothing. + } + $this->releaseLock(); + + // Clean up. + $result = $this->progressImporting(); + if ($result == FEEDS_BATCH_COMPLETE || isset($e)) { + $this->imported = time(); + $this->log('import', 'Imported in !s s', array('!s' => $this->imported - $this->state[FEEDS_START]), WATCHDOG_INFO); + module_invoke_all('feeds_after_import', $this); + unset($this->fetcher_result, $this->state); + } + $this->save(); + if (isset($e)) { + throw $e; + } + return $result; + } + + /** + * Remove all items from a feed. + * + * This method only executes the current batch chunk, then returns. If you are + * looking to delete all items of a source, use FeedsSource::startClear() + * instead. + * + * @return + * FEEDS_BATCH_COMPLETE if the clearing process finished. A decimal between + * 0.0 and 0.9 periodic if clearing is still in progress. + * + * @throws + * Throws Exception if an error occurs when clearing. + */ + public function clear() { + $this->acquireLock(); + try { + $this->importer->fetcher->clear($this); + $this->importer->parser->clear($this); + $this->importer->processor->clear($this); + } + catch (Exception $e) { + // Do nothing. + } + $this->releaseLock(); + + // Clean up. + $result = $this->progressClearing(); + if ($result == FEEDS_BATCH_COMPLETE || isset($e)) { + module_invoke_all('feeds_after_clear', $this); + unset($this->state); + } + $this->save(); + if (isset($e)) { + throw $e; + } + return $result; + } + + /** + * Report progress as float between 0 and 1. 1 = FEEDS_BATCH_COMPLETE. + */ + public function progressParsing() { + return $this->state(FEEDS_PARSE)->progress; + } + + /** + * Report progress as float between 0 and 1. 1 = FEEDS_BATCH_COMPLETE. + */ + public function progressImporting() { + $fetcher = $this->state(FEEDS_FETCH); + $parser = $this->state(FEEDS_PARSE); + if ($fetcher->progress == FEEDS_BATCH_COMPLETE && $parser->progress == FEEDS_BATCH_COMPLETE) { + return FEEDS_BATCH_COMPLETE; + } + // Fetching envelops parsing. + // @todo: this assumes all fetchers neatly use total. May not be the case. + $fetcher_fraction = $fetcher->total ? 1.0 / $fetcher->total : 1.0; + $parser_progress = $parser->progress * $fetcher_fraction; + $result = $fetcher->progress - $fetcher_fraction + $parser_progress; + if ($result == FEEDS_BATCH_COMPLETE) { + return 0.99; + } + return $result; + } + + /** + * Report progress on clearing. + */ + public function progressClearing() { + return $this->state(FEEDS_PROCESS_CLEAR)->progress; + } + + /** + * Return a state object for a given stage. Lazy instantiates new states. + * + * @todo Rename getConfigFor() accordingly to config(). + * + * @param $stage + * One of FEEDS_FETCH, FEEDS_PARSE, FEEDS_PROCESS or FEEDS_PROCESS_CLEAR. + * + * @return + * The FeedsState object for the given stage. + */ + public function state($stage) { + if (!is_array($this->state)) { + $this->state = array(); + } + if (!isset($this->state[$stage])) { + $this->state[$stage] = new FeedsState(); + } + return $this->state[$stage]; + } + + /** + * Count items imported by this source. + */ + public function itemCount() { + return $this->importer->processor->itemCount($this); + } + + /** + * Save configuration. + */ + public function save() { + // Alert implementers of FeedsSourceInterface to the fact that we're saving. + foreach ($this->importer->plugin_types as $type) { + $this->importer->$type->sourceSave($this); + } + $config = $this->getConfig(); + + // Store the source property of the fetcher in a separate column so that we + // can do fast lookups on it. + $source = ''; + if (isset($config[get_class($this->importer->fetcher)]['source'])) { + $source = $config[get_class($this->importer->fetcher)]['source']; + } + $object = array( + 'id' => $this->id, + 'feed_nid' => $this->feed_nid, + 'imported' => $this->imported, + 'config' => $config, + 'source' => $source, + 'state' => isset($this->state) ? $this->state : FALSE, + 'fetcher_result' => isset($this->fetcher_result) ? $this->fetcher_result : FALSE, + ); + if (db_query_range("SELECT 1 FROM {feeds_source} WHERE id = :id AND feed_nid = :nid", 0, 1, array(':id' => $this->id, ':nid' => $this->feed_nid))->fetchField()) { + drupal_write_record('feeds_source', $object, array('id', 'feed_nid')); + } + else { + drupal_write_record('feeds_source', $object); + } + } + + /** + * Load configuration and unpack. + * + * @todo Patch CTools to move constants from export.inc to ctools.module. + */ + public function load() { + if ($record = db_query("SELECT imported, config, state, fetcher_result FROM {feeds_source} WHERE id = :id AND feed_nid = :nid", array(':id' => $this->id, ':nid' => $this->feed_nid))->fetchObject()) { + // While FeedsSource cannot be exported, we still use CTool's export.inc + // export definitions. + ctools_include('export'); + $this->export_type = EXPORT_IN_DATABASE; + $this->imported = $record->imported; + $this->config = unserialize($record->config); + if (!empty($record->state)) { + $this->state = unserialize($record->state); + } + if (!empty($record->fetcher_result)) { + $this->fetcher_result = unserialize($record->fetcher_result); + } + } + } + + /** + * Delete configuration. Removes configuration information + * from database, does not delete configuration itself. + */ + public function delete() { + // Alert implementers of FeedsSourceInterface to the fact that we're + // deleting. + foreach ($this->importer->plugin_types as $type) { + $this->importer->$type->sourceDelete($this); + } + db_delete('feeds_source') + ->condition('id', $this->id) + ->condition('feed_nid', $this->feed_nid) + ->execute(); + // Remove from schedule. + $job = array( + 'type' => $this->id, + 'id' => $this->feed_nid, + ); + JobScheduler::get('feeds_source_import')->remove($job); + } + + /** + * Only return source if configuration is persistent and valid. + * + * @see FeedsConfigurable::existing(). + */ + public function existing() { + // If there is no feed nid given, there must be no content type specified. + // If there is a feed nid given, there must be a content type specified. + // Ensure that importer is persistent (= defined in code or DB). + // Ensure that source is persistent (= defined in DB). + if ((empty($this->feed_nid) && empty($this->importer->config['content_type'])) || + (!empty($this->feed_nid) && !empty($this->importer->config['content_type']))) { + $this->importer->existing(); + return parent::existing(); + } + throw new FeedsNotExistingException(t('Source configuration not valid.')); + } + + /** + * Returns the configuration for a specific client class. + * + * @param FeedsSourceInterface $client + * An object that is an implementer of FeedsSourceInterface. + * + * @return + * An array stored for $client. + */ + public function getConfigFor(FeedsSourceInterface $client) { + $class = get_class($client); + return isset($this->config[$class]) ? $this->config[$class] : $client->sourceDefaults(); + } + + /** + * Sets the configuration for a specific client class. + * + * @param FeedsSourceInterface $client + * An object that is an implementer of FeedsSourceInterface. + * @param $config + * The configuration for $client. + * + * @return + * An array stored for $client. + */ + public function setConfigFor(FeedsSourceInterface $client, $config) { + $this->config[get_class($client)] = $config; + } + + /** + * Return defaults for feed configuration. + */ + public function configDefaults() { + // Collect information from plugins. + $defaults = array(); + foreach ($this->importer->plugin_types as $type) { + if ($this->importer->$type->hasSourceConfig()) { + $defaults[get_class($this->importer->$type)] = $this->importer->$type->sourceDefaults(); + } + } + return $defaults; + } + + /** + * Override parent::configForm(). + */ + public function configForm(&$form_state) { + // Collect information from plugins. + $form = array(); + foreach ($this->importer->plugin_types as $type) { + if ($this->importer->$type->hasSourceConfig()) { + $class = get_class($this->importer->$type); + $config = isset($this->config[$class]) ? $this->config[$class] : array(); + $form[$class] = $this->importer->$type->sourceForm($config); + $form[$class]['#tree'] = TRUE; + } + } + return $form; + } + + /** + * Override parent::configFormValidate(). + */ + public function configFormValidate(&$values) { + foreach ($this->importer->plugin_types as $type) { + $class = get_class($this->importer->$type); + if (isset($values[$class]) && $this->importer->$type->hasSourceConfig()) { + $this->importer->$type->sourceFormValidate($values[$class]); + } + } + } + + /** + * Writes to feeds log. + */ + public function log($type, $message, $variables = array(), $severity = WATCHDOG_NOTICE) { + feeds_log($this->id, $this->feed_nid, $type, $message, $variables, $severity); + } + + /** + * Background job helper. Starts a background job using Job Scheduler. + * + * Execute the first batch chunk of a background job on the current page load, + * moves the rest of the job processing to a cron powered background job. + * + * Executing the first batch chunk is important, otherwise, when a user + * submits a source for import or clearing, we will leave her without any + * visual indicators of an ongoing job. + * + * @see FeedsSource::startImport(). + * @see FeedsSource::startClear(). + * + * @param $method + * Method to execute on importer; one of 'import' or 'clear'. + * + * @throws Exception $e + */ + protected function startBackgroundJob($method) { + if (FEEDS_BATCH_COMPLETE != $this->$method()) { + $job = array( + 'type' => $this->id, + 'id' => $this->feed_nid, + 'period' => 0, + 'periodic' => FALSE, + ); + JobScheduler::get("feeds_source_{$method}")->set($job); + } + } + + /** + * Batch API helper. Starts a Batch API job. + * + * @see FeedsSource::startImport(). + * @see FeedsSource::startClear(). + * @see feeds_batch() + * + * @param $title + * Title to show to user when executing batch. + * @param $method + * Method to execute on importer; one of 'import' or 'clear'. + */ + protected function startBatchAPIJob($title, $method) { + $batch = array( + 'title' => $title, + 'operations' => array( + array('feeds_batch', array($method, $this->id, $this->feed_nid)), + ), + 'progress_message' => '', + ); + batch_set($batch); + } + + /** + * Acquires a lock for this source. + * + * @throws FeedsLockException + * If a lock for the requested job could not be acquired. + */ + protected function acquireLock() { + if (!lock_acquire("feeds_source_{$this->id}_{$this->feed_nid}", 60.0)) { + throw new FeedsLockException(t('Cannot acquire lock for source @id / @feed_nid.', array('@id' => $this->id, '@feed_nid' => $this->feed_nid))); + } + } + + /** + * Releases a lock for this source. + */ + protected function releaseLock() { + lock_release("feeds_source_{$this->id}_{$this->feed_nid}"); + } +} diff --git a/sites/all/modules/feeds/libraries/ParserCSV.inc b/sites/all/modules/feeds/libraries/ParserCSV.inc new file mode 100644 index 0000000000000000000000000000000000000000..6a3ff7087cb7949494077a9d38f5cc063683ee6c --- /dev/null +++ b/sites/all/modules/feeds/libraries/ParserCSV.inc @@ -0,0 +1,327 @@ +<?php + +/** + * @file + * Contains CSV Parser. + * + * Functions in this file are independent of the Feeds specific implementation. + * Thanks to jpetso http://drupal.org/user/56020 for most of the code in this + * file. + */ + +/** + * Text lines from file iterator. + */ +class ParserCSVIterator implements Iterator { + private $handle; + private $currentLine; + private $currentPos; + + public function __construct($filepath) { + $this->handle = fopen($filepath, 'r'); + $this->currentLine = NULL; + $this->currentPos = NULL; + } + + function __destruct() { + if ($this->handle) { + fclose($this->handle); + } + } + + public function rewind($pos = 0) { + if ($this->handle) { + fseek($this->handle, $pos); + $this->next(); + } + } + + public function next() { + if ($this->handle) { + $this->currentLine = feof($this->handle) ? NULL : fgets($this->handle); + $this->currentPos = ftell($this->handle); + return $this->currentLine; + } + } + + public function valid() { + return isset($this->currentLine); + } + + public function current() { + return $this->currentLine; + } + + public function currentPos() { + return $this->currentPos; + } + + public function key() { + return 'line'; + } +} + +/** + * Functionality to parse CSV files into a two dimensional array. + */ +class ParserCSV { + private $delimiter; + private $skipFirstLine; + private $columnNames; + private $timeout; + private $timeoutReached; + private $startByte; + private $lineLimit; + private $lastLinePos; + + public function __construct() { + $this->delimiter = ','; + $this->skipFirstLine = FALSE; + $this->columnNames = FALSE; + $this->timeout = FALSE; + $this->timeoutReached = FALSE; + $this->startByte = 0; + $this->lineLimit = 0; + $this->lastLinePos = 0; + } + + /** + * Set the column delimiter string. + * By default, the comma (',') is used as delimiter. + */ + public function setDelimiter($delimiter) { + $this->delimiter = $delimiter; + } + + /** + * Set this to TRUE if the parser should skip the first line of the CSV text, + * which might be desired if the first line contains the column names. + * By default, this is set to FALSE and the first line is not skipped. + */ + public function setSkipFirstLine($skipFirstLine) { + $this->skipFirstLine = $skipFirstLine; + } + + /** + * Specify an array of column names if you know them in advance, or FALSE + * (which is the default) to unset any prior column names. If no column names + * are set, the parser will put each row into a simple numerically indexed + * array. If column names are given, the parser will create arrays with + * these column names as array keys instead. + */ + public function setColumnNames($columnNames) { + $this->columnNames = $columnNames; + } + + /** + * Define the time (in milliseconds) after which the parser stops parsing, + * even if it has not yet finished processing the CSV data. If the timeout + * has been reached before parsing is done, the parse() method will return + * an incomplete list of rows - a single row will never be cut off in the + * middle, though. By default, no timeout (@p $timeout == FALSE) is defined. + * + * You can check if the timeout has been reached by calling the + * timeoutReached() method after parse() has been called. + */ + public function setTimeout($timeout) { + $this->timeout = $timeout; + } + + /** + * After calling the parse() method, determine if the timeout (set by the + * setTimeout() method) has been reached. + * + * @deprecated Use lastLinePos() instead to determine whether a file has + * finished parsing. + */ + public function timeoutReached() { + return $this->timeoutReached; + } + + /** + * Define the number of lines to parse in one parsing operation. + * + * By default, all lines of a file are being parsed. + */ + public function setLineLimit($lines) { + $this->lineLimit = $lines; + } + + /** + * Get the byte number where the parser left off after last parse() call. + * + * @return + * 0 if all lines or no line has been parsed, the byte position of where a + * timeout or the line limit has been reached otherwise. This position can be + * used to set the start byte for the next iteration after parse() has + * reached the timeout set with setTimeout() or the line limit set with + * setLineLimit(). + * + * @see ParserCSV::setStartByte() + */ + public function lastLinePos() { + return $this->lastLinePos; + } + + /** + * Set the byte where file should be started to read. + * + * Useful when parsing a file in batches. + */ + public function setStartByte($start) { + return $this->startByte = $start; + } + + /** + * Parse CSV files into a two dimensional array. + * + * @param Iterator $lineIterator + * An Iterator object that yields line strings, e.g. ParserCSVIterator. + * @param $start + * The byte number from where to start parsing the file. + * @param $lines + * The number of lines to parse, 0 for all lines. + * @return + * Two dimensional array that contains the data in the CSV file. + */ + public function parse(Iterator $lineIterator) { + $skipLine = $this->skipFirstLine; + $rows = array(); + + $this->timeoutReached = FALSE; + $this->lastLinePos = 0; + $maxTime = empty($this->timeout) ? FALSE : (microtime() + $this->timeout); + $linesParsed = 0; + + for ($lineIterator->rewind($this->startByte); $lineIterator->valid(); $lineIterator->next()) { + + // Make really sure we've got lines without trailing newlines. + $line = trim($lineIterator->current(), "\r\n"); + + // Skip empty lines. + if (empty($line)) { + continue; + } + // If the first line contains column names, skip it. + if ($skipLine) { + $skipLine = FALSE; + continue; + } + + // The actual parser. explode() is unfortunately not suitable because the + // delimiter might be located inside a quoted field, and that would break + // the field and/or require additional effort to re-join the fields. + $quoted = FALSE; + $currentIndex = 0; + $currentField = ''; + $fields = array(); + + // We must use strlen() as we're parsing byte by byte using strpos(), so + // drupal_strlen() will not work properly. + while ($currentIndex <= strlen($line)) { + if ($quoted) { + $nextQuoteIndex = strpos($line, '"', $currentIndex); + + if ($nextQuoteIndex === FALSE) { + // There's a line break before the quote is closed, so fetch the + // next line and start from there. + $currentField .= substr($line, $currentIndex); + $lineIterator->next(); + + if (!$lineIterator->valid()) { + // Whoa, an unclosed quote! Well whatever, let's just ignore + // that shortcoming and record it nevertheless. + $fields[] = $currentField; + break; + } + // Ok, so, on with fetching the next line, as mentioned above. + $currentField .= "\n"; + $line = trim($lineIterator->current(), "\r\n"); + $currentIndex = 0; + continue; + } + + // There's actually another quote in this line... + // find out whether it's escaped or not. + $currentField .= substr($line, $currentIndex, $nextQuoteIndex - $currentIndex); + + if (isset($line[$nextQuoteIndex + 1]) && $line[$nextQuoteIndex + 1] === '"') { + // Escaped quote, add a single one to the field and proceed quoted. + $currentField .= '"'; + $currentIndex = $nextQuoteIndex + 2; + } + else { + // End of the quoted section, close the quote and let the + // $quoted == FALSE block finalize the field. + $quoted = FALSE; + $currentIndex = $nextQuoteIndex + 1; + } + } + else { // $quoted == FALSE + // First, let's find out where the next character of interest is. + $nextQuoteIndex = strpos($line, '"', $currentIndex); + $nextDelimiterIndex = strpos($line, $this->delimiter, $currentIndex); + + if ($nextQuoteIndex === FALSE) { + $nextIndex = $nextDelimiterIndex; + } + elseif ($nextDelimiterIndex === FALSE) { + $nextIndex = $nextQuoteIndex; + } + else { + $nextIndex = min($nextQuoteIndex, $nextDelimiterIndex); + } + + if ($nextIndex === FALSE) { + // This line is done, add the rest of it as last field. + $currentField .= substr($line, $currentIndex); + $fields[] = $currentField; + break; + } + elseif ($line[$nextIndex] === $this->delimiter[0]) { + $length = ($nextIndex + strlen($this->delimiter) - 1) - $currentIndex; + $currentField .= substr($line, $currentIndex, $length); + $fields[] = $currentField; + $currentField = ''; + $currentIndex += $length + 1; + // Continue with the next field. + } + else { // $line[$nextIndex] == '"' + $quoted = TRUE; + $currentField .= substr($line, $currentIndex, $nextIndex - $currentIndex); + $currentIndex = $nextIndex + 1; + // Continue this field in the $quoted == TRUE block. + } + } + } + // End of CSV parser. We've now got all the fields of the line as strings + // in the $fields array. + + if (empty($this->columnNames)) { + $row = $fields; + } + else { + $row = array(); + foreach ($this->columnNames as $columnName) { + $field = array_shift($fields); + $row[$columnName] = isset($field) ? $field : ''; + } + } + $rows[] = $row; + + // Quit parsing if timeout has been reached or requested lines have been + // reached. + if (!empty($maxTime) && microtime() > $maxTime) { + $this->timeoutReached = TRUE; + $this->lastLinePos = $lineIterator->currentPos(); + break; + } + $linesParsed++; + if ($this->lineLimit && $linesParsed >= $this->lineLimit) { + $this->lastLinePos = $lineIterator->currentPos(); + break; + } + } + return $rows; + } +} diff --git a/sites/all/modules/feeds/libraries/PuSHSubscriber.inc b/sites/all/modules/feeds/libraries/PuSHSubscriber.inc new file mode 100644 index 0000000000000000000000000000000000000000..c5926defff9f568d8a45a63457f0a9f9055027f2 --- /dev/null +++ b/sites/all/modules/feeds/libraries/PuSHSubscriber.inc @@ -0,0 +1,390 @@ +<?php + +/** + * @file + * Pubsubhubbub subscriber library. + * + * Readme + * http://github.com/lxbarth/PuSHSubscriber + * + * License + * http://github.com/lxbarth/PuSHSubscriber/blob/master/LICENSE.txt + */ + +/** + * PubSubHubbub subscriber. + */ +class PuSHSubscriber { + protected $domain; + protected $subscriber_id; + protected $subscription_class; + protected $env; + + /** + * Singleton. + * + * PuSHSubscriber identifies a unique subscription by a domain and a numeric + * id. The numeric id is assumed to e unique in its domain. + * + * @param $domain + * A string that identifies the domain in which $subscriber_id is unique. + * @param $subscriber_id + * A numeric subscriber id. + * @param $subscription_class + * The class to use for handling subscriptions. Class MUST implement + * PuSHSubscriberSubscriptionInterface + * @param PuSHSubscriberEnvironmentInterface $env + * Environmental object for messaging and logging. + */ + public static function instance($domain, $subscriber_id, $subscription_class, PuSHSubscriberEnvironmentInterface $env) { + static $subscribers; + if (!isset($subscriber[$domain][$subscriber_id])) { + $subscriber = new PuSHSubscriber($domain, $subscriber_id, $subscription_class, $env); + } + return $subscriber; + } + + /** + * Protect constructor. + */ + protected function __construct($domain, $subscriber_id, $subscription_class, PuSHSubscriberEnvironmentInterface $env) { + $this->domain = $domain; + $this->subscriber_id = $subscriber_id; + $this->subscription_class = $subscription_class; + $this->env = $env; + } + + /** + * Subscribe to a given URL. Attempt to retrieve 'hub' and 'self' links from + * document at $url and issue a subscription request to the hub. + * + * @param $url + * The URL of the feed to subscribe to. + * @param $callback_url + * The full URL that hub should invoke for subscription verification or for + * notifications. + * @param $hub + * The URL of a hub. If given overrides the hub URL found in the document + * at $url. + */ + public function subscribe($url, $callback_url, $hub = '') { + // Fetch document, find rel=hub and rel=self. + // If present, issue subscription request. + $request = curl_init($url); + curl_setopt($request, CURLOPT_FOLLOWLOCATION, TRUE); + curl_setopt($request, CURLOPT_RETURNTRANSFER, TRUE); + $data = curl_exec($request); + if (curl_getinfo($request, CURLINFO_HTTP_CODE) == 200) { + try { + $xml = @ new SimpleXMLElement($data); + $xml->registerXPathNamespace('atom', 'http://www.w3.org/2005/Atom'); + if (empty($hub) && $hub = @current($xml->xpath("//atom:link[attribute::rel='hub']"))) { + $hub = (string) $hub->attributes()->href; + } + if ($self = @current($xml->xpath("//atom:link[attribute::rel='self']"))) { + $self = (string) $self->attributes()->href; + } + } + catch (Exception $e) { + // Do nothing. + } + } + curl_close($request); + // Fall back to $url if $self is not given. + if (!$self) { + $self = $url; + } + if (!empty($hub) && !empty($self)) { + $this->request($hub, $self, 'subscribe', $callback_url); + } + } + + /** + * @todo Unsubscribe from a hub. + * @todo Make sure we unsubscribe with the correct topic URL as it can differ + * from the initial subscription URL. + * + * @param $topic_url + * The URL of the topic to unsubscribe from. + * @param $callback_url + * The callback to unsubscribe. + */ + public function unsubscribe($topic_url, $callback_url) { + if ($sub = $this->subscription()) { + $this->request($sub->hub, $sub->topic, 'unsubscribe', $callback_url); + $sub->delete(); + } + } + + /** + * Request handler for subscription callbacks. + */ + public function handleRequest($callback) { + if (isset($_GET['hub_challenge'])) { + $this->verifyRequest(); + } + // No subscription notification has ben sent, we are being notified. + else { + if ($raw = $this->receive()) { + $callback($raw, $this->domain, $this->subscriber_id); + } + } + } + + /** + * Receive a notification. + * + * @param $ignore_signature + * If FALSE, only accept payload if there is a signature present and the + * signature matches the payload. Warning: setting to TRUE results in + * unsafe behavior. + * + * @return + * An XML string that is the payload of the notification if valid, FALSE + * otherwise. + */ + public function receive($ignore_signature = FALSE) { + /** + * Verification steps: + * + * 1) Verify that this is indeed a POST reuest. + * 2) Verify that posted string is XML. + * 3) Per default verify sender of message by checking the message's + * signature against the shared secret. + */ + if ($_SERVER['REQUEST_METHOD'] == 'POST') { + $raw = file_get_contents('php://input'); + if (@simplexml_load_string($raw)) { + if ($ignore_signature) { + return $raw; + } + if (isset($_SERVER['HTTP_X_HUB_SIGNATURE']) && ($sub = $this->subscription())) { + $result = array(); + parse_str($_SERVER['HTTP_X_HUB_SIGNATURE'], $result); + if (isset($result['sha1']) && $result['sha1'] == hash_hmac('sha1', $raw, $sub->secret)) { + return $raw; + } + else { + $this->log('Could not verify signature.', 'error'); + } + } + else { + $this->log('No signature present.', 'error'); + } + } + } + return FALSE; + } + + /** + * Verify a request. After a hub has received a subscribe or unsubscribe + * request (see PuSHSubscriber::request()) it sends back a challenge verifying + * that an action indeed was requested ($_GET['hub_challenge']). This + * method handles the challenge. + */ + public function verifyRequest() { + if (isset($_GET['hub_challenge'])) { + /** + * If a subscription is present, compare the verify token. If the token + * matches, set the status on the subscription record and confirm + * positive. + * + * If we cannot find a matching subscription and the hub checks on + * 'unsubscribe' confirm positive. + * + * In all other cases confirm negative. + */ + if ($sub = $this->subscription()) { + if ($_GET['hub_verify_token'] == $sub->post_fields['hub.verify_token']) { + if ($_GET['hub_mode'] == 'subscribe' && $sub->status == 'subscribe') { + $sub->status = 'subscribed'; + $sub->post_fields = array(); + $sub->save(); + $this->log('Verified "subscribe" request.'); + $verify = TRUE; + } + elseif ($_GET['hub_mode'] == 'unsubscribe' && $sub->status == 'unsubscribe') { + $sub->status = 'unsubscribed'; + $sub->post_fields = array(); + $sub->save(); + $this->log('Verified "unsubscribe" request.'); + $verify = TRUE; + } + } + } + elseif ($_GET['hub_mode'] == 'unsubscribe') { + $this->log('Verified "unsubscribe" request.'); + $verify = TRUE; + } + if ($verify) { + header('HTTP/1.1 200 "Found"', NULL, 200); + print $_GET['hub_challenge']; + drupal_exit(); + } + } + header('HTTP/1.1 404 "Not Found"', NULL, 404); + $this->log('Could not verify subscription.', 'error'); + drupal_exit(); + } + + /** + * Issue a subscribe or unsubcribe request to a PubsubHubbub hub. + * + * @param $hub + * The URL of the hub's subscription endpoint. + * @param $topic + * The topic URL of the feed to subscribe to. + * @param $mode + * 'subscribe' or 'unsubscribe'. + * @param $callback_url + * The subscriber's notifications callback URL. + * + * Compare to http://pubsubhubbub.googlecode.com/svn/trunk/pubsubhubbub-core-0.2.html#anchor5 + * + * @todo Make concurrency safe. + */ + protected function request($hub, $topic, $mode, $callback_url) { + $secret = hash('sha1', uniqid(rand(), TRUE)); + $post_fields = array( + 'hub.callback' => $callback_url, + 'hub.mode' => $mode, + 'hub.topic' => $topic, + 'hub.verify' => 'sync', + 'hub.lease_seconds' => '', // Permanent subscription. + 'hub.secret' => $secret, + 'hub.verify_token' => md5(session_id() . rand()), + ); + $sub = new $this->subscription_class($this->domain, $this->subscriber_id, $hub, $topic, $secret, $mode, $post_fields); + $sub->save(); + // Issue subscription request. + $request = curl_init($hub); + curl_setopt($request, CURLOPT_POST, TRUE); + curl_setopt($request, CURLOPT_POSTFIELDS, $post_fields); + curl_setopt($request, CURLOPT_RETURNTRANSFER, TRUE); + curl_exec($request); + $code = curl_getinfo($request, CURLINFO_HTTP_CODE); + if (in_array($code, array(202, 204))) { + $this->log("Positive response to \"$mode\" request ($code)."); + } + else { + $sub->status = $mode . ' failed'; + $sub->save(); + $this->log("Error issuing \"$mode\" request to $hub ($code).", 'error'); + } + curl_close($request); + } + + /** + * Get the subscription associated with this subscriber. + * + * @return + * A PuSHSubscriptionInterface object if a subscription exist, NULL + * otherwise. + */ + public function subscription() { + return call_user_func(array($this->subscription_class, 'load'), $this->domain, $this->subscriber_id); + } + + /** + * Determine whether this subscriber is successfully subscribed or not. + */ + public function subscribed() { + if ($sub = $this->subscription()) { + if ($sub->status == 'subscribed') { + return TRUE; + } + } + return FALSE; + } + + /** + * Helper for messaging. + */ + protected function msg($msg, $level = 'status') { + $this->env->msg($msg, $level); + } + + /** + * Helper for logging. + */ + protected function log($msg, $level = 'status') { + $this->env->log("{$this->domain}:{$this->subscriber_id}\t$msg", $level); + } +} + +/** + * Implement to provide a storage backend for subscriptions. + * + * Variables passed in to the constructor must be accessible as public class + * variables. + */ +interface PuSHSubscriptionInterface { + /** + * @param $domain + * A string that defines the domain in which the subscriber_id is unique. + * @param $subscriber_id + * A unique numeric subscriber id. + * @param $hub + * The URL of the hub endpoint. + * @param $topic + * The topic to subscribe to. + * @param $secret + * A secret key used for message authentication. + * @param $status + * The status of the subscription. + * 'subscribe' - subscribing to a feed. + * 'unsubscribe' - unsubscribing from a feed. + * 'subscribed' - subscribed. + * 'unsubscribed' - unsubscribed. + * 'subscribe failed' - subscribe request failed. + * 'unsubscribe failed' - unsubscribe request failed. + * @param $post_fields + * An array of the fields posted to the hub. + */ + public function __construct($domain, $subscriber_id, $hub, $topic, $secret, $status = '', $post_fields = ''); + + /** + * Save a subscription. + */ + public function save(); + + /** + * Load a subscription. + * + * @return + * A PuSHSubscriptionInterface object if a subscription exist, NULL + * otherwise. + */ + public static function load($domain, $subscriber_id); + + /** + * Delete a subscription. + */ + public function delete(); +} + +/** + * Implement to provide environmental functionality like user messages and + * logging. + */ +interface PuSHSubscriberEnvironmentInterface { + /** + * A message to be displayed to the user on the current page load. + * + * @param $msg + * A string that is the message to be displayed. + * @param $level + * A string that is either 'status', 'warning' or 'error'. + */ + public function msg($msg, $level = 'status'); + + /** + * A log message to be logged to the database or the file system. + * + * @param $msg + * A string that is the message to be displayed. + * @param $level + * A string that is either 'status', 'warning' or 'error'. + */ + public function log($msg, $level = 'status'); +} diff --git a/sites/all/modules/feeds/libraries/common_syndication_parser.inc b/sites/all/modules/feeds/libraries/common_syndication_parser.inc new file mode 100644 index 0000000000000000000000000000000000000000..1b7ef612fd4312f31437e82f39b7d160749ed0d5 --- /dev/null +++ b/sites/all/modules/feeds/libraries/common_syndication_parser.inc @@ -0,0 +1,590 @@ +<?php + +/** + * @file + * Downloading and parsing functions for Common Syndication Parser. + * Pillaged from FeedAPI common syndication parser. + * + * @todo Restructure. OO could work wonders here. + * @todo Write unit tests. + * @todo Keep in Feeds project or host on Drupal? + */ + +/** + * Parse the feed into a data structure. + * + * @param $feed + * The feed object (contains the URL or the parsed XML structure. + * @return + * stdClass The structured datas extracted from the feed. + */ +function common_syndication_parser_parse($string) { + if (!defined('LIBXML_VERSION') || (version_compare(phpversion(), '5.1.0', '<'))) { + @ $xml = simplexml_load_string($string, NULL); + } + else { + @ $xml = simplexml_load_string($string, NULL, LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_NOCDATA); + } + + // Got a malformed XML. + if ($xml === FALSE || is_null($xml)) { + return FALSE; + } + $feed_type = _parser_common_syndication_feed_format_detect($xml); + if ($feed_type == "atom1.0") { + return _parser_common_syndication_atom10_parse($xml); + } + if ($feed_type == "RSS2.0" || $feed_type == "RSS0.91" || $feed_type == "RSS0.92") { + return _parser_common_syndication_RSS20_parse($xml); + } + if ($feed_type == "RDF") { + return _parser_common_syndication_RDF10_parse($xml); + } + return FALSE; +} + +/** + * Get the cached version of the <var>$url</var> + */ +function _parser_common_syndication_cache_get($url) { + $cache_file = _parser_common_syndication_sanitize_cache() . '/' . md5($url); + if (file_exists($cache_file)) { + $file_content = file_get_contents($cache_file); + return unserialize($file_content); + } + return FALSE; +} + +/** + * Determine the feed format of a SimpleXML parsed object structure. + * + * @param $xml + * SimpleXML-preprocessed feed. + * @return + * The feed format short description or FALSE if not compatible. + */ +function _parser_common_syndication_feed_format_detect($xml) { + if (!is_object($xml)) { + return FALSE; + } + $attr = $xml->attributes(); + $type = strtolower($xml->getName()); + if (isset($xml->entry) && $type == "feed") { + return "atom1.0"; + } + if ($type == "rss" && $attr["version"] == "2.0") { + return "RSS2.0"; + } + if ($type == "rdf" && isset($xml->channel)) { + return "RDF"; + } + if ($type == "rss" && $attr["version"] == "0.91") { + return "RSS0.91"; + } + if ($type == "rss" && $attr["version"] == "0.92") { + return "RSS0.92"; + } + return FALSE; +} + +/** + * Parse atom feeds. + */ +function _parser_common_syndication_atom10_parse($feed_XML) { + $parsed_source = array(); + + $ns = array( + "georss" => "http://www.georss.org/georss", + ); + + $base = $feed_XML->xpath("@base"); + $base = (string) array_shift($base); + if (!valid_url($base, TRUE)) { + $base = FALSE; + } + + // Detect the title + $parsed_source['title'] = isset($feed_XML->title) ? _parser_common_syndication_title("{$feed_XML->title}") : ""; + // Detect the description + $parsed_source['description'] = isset($feed_XML->subtitle) ? "{$feed_XML->subtitle}" : ""; + + $parsed_source['link'] = _parser_common_syndication_link($feed_XML->link); + if (valid_url($parsed_source['link']) && !valid_url($parsed_source['link'], TRUE) && !empty($base)) { + $parsed_source['link'] = $base . $parsed_source['link']; + } + + $parsed_source['items'] = array(); + + foreach ($feed_XML->entry as $news) { + + $original_url = NULL; + $guid = !empty($news->id) ? "{$news->id}" : NULL; + if (valid_url($guid, TRUE)) { + $original_url = $guid; + } + + $georss = (array)$news->children($ns["georss"]); + $geoname = ''; + if (isset($georss['featureName'])) { + $geoname = "{$georss['featureName']}"; + } + + $latlon = + $lat = + $lon = NULL; + if (isset($georss['point'])) { + $latlon = explode(' ', $georss['point']); + $lat = "{$latlon[0]}"; + $lon = "{$latlon[1]}"; + if (!$geoname) { + $geoname = "{$lat} {$lon}"; + } + } + + $additional_taxonomies = array(); + if (isset($news->category)) { + $additional_taxonomies['ATOM Categories'] = array(); + $additional_taxonomies['ATOM Domains'] = array(); + foreach ($news->category as $category) { + if (isset($category['scheme'])) { + $domain = "{$category['scheme']}"; + if (!empty($domain)) { + if (!isset($additional_taxonomies['ATOM Domains'][$domain])) { + $additional_taxonomies['ATOM Domains'][$domain] = array(); + } + $additional_taxonomies['ATOM Domains'][$domain][] = count($additional_taxonomies['ATOM Categories']) - 1; + } + } + $additional_taxonomies['ATOM Categories'][] = "{$category['term']}"; + } + } + + $title = "{$news->title}"; + + $body = ''; + if (!empty($news->content)) { + foreach ($news->content->children() as $child) { + $body .= $child->asXML(); + } + $body .= "{$news->content}"; + } + elseif (!empty($news->summary)) { + foreach ($news->summary->children() as $child) { + $body .= $child->asXML(); + } + $body .= "{$news->summary}"; + } + + if (!empty($news->content['src'])) { + // some src elements in some valid atom feeds contained no urls at all + if (valid_url("{$news->content['src']}", TRUE)) { + $original_url = "{$news->content['src']}"; + } + } + + $author_found = FALSE; + if (!empty($news->source->author->name)) { + $original_author = "{$news->source->author->name}"; + $author_found = TRUE; + } + elseif (!empty($news->author->name)) { + $original_author = "{$news->author->name}"; + $author_found = TRUE; + } + if (!empty($feed_XML->author->name) && !$author_found) { + $original_author = "{$feed_XML->author->name}"; + } + + $original_url = _parser_common_syndication_link($news->link); + + $item = array(); + $item['title'] = _parser_common_syndication_title($title, $body); + $item['description'] = $body; + $item['author_name'] = $original_author; + + // Fall back to updated for timestamp if both published and issued are + // empty. + if (isset($news->published)) { + $item['timestamp'] = _parser_common_syndication_parse_date("{$news->published}"); + } + elseif (isset($news->issued)) { + $item['timestamp'] = _parser_common_syndication_parse_date("{$news->issued}"); + } + elseif (isset($news->updated)) { + $item['timestamp'] = _parser_common_syndication_parse_date("{$news->updated}"); + } + + $item['url'] = trim($original_url); + if (valid_url($item['url']) && !valid_url($item['url'], TRUE) && !empty($base)) { + $item['url'] = $base . $item['url']; + } + // Fall back on URL if GUID is empty. + if (!empty($guid)) { + $item['guid'] = $guid; + } + else { + $item['guid'] = $item['url']; + } + $item['geolocations'] = array(); + if ($lat && $lon) { + $item['geolocations'] = array( + array( + 'name' => $geoname, + 'lat' => $lat, + 'lon' => $lon, + ), + ); + } + $item['tags'] = isset($additional_taxonomies['ATOM Categories']) ? $additional_taxonomies['ATOM Categories'] : array(); + $item['domains'] = isset($additional_taxonomies['ATOM Domains']) ? $additional_taxonomies['ATOM Domains'] : array(); + $parsed_source['items'][] = $item; + } + return $parsed_source; +} + +/** + * Parse RDF Site Summary (RSS) 1.0 feeds in RDF/XML format. + * + * @see http://web.resource.org/rss/1.0/ + */ +function _parser_common_syndication_RDF10_parse($feed_XML) { + // Declare some canonical standard prefixes for well-known namespaces: + static $canonical_namespaces = array( + 'rdf' => 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', + 'rdfs' => 'http://www.w3.org/2000/01/rdf-schema#', + 'xsi' => 'http://www.w3.org/2001/XMLSchema-instance#', + 'xsd' => 'http://www.w3.org/2001/XMLSchema#', + 'owl' => 'http://www.w3.org/2002/07/owl#', + 'dc' => 'http://purl.org/dc/elements/1.1/', + 'dcterms' => 'http://purl.org/dc/terms/', + 'dcmitype' => 'http://purl.org/dc/dcmitype/', + 'foaf' => 'http://xmlns.com/foaf/0.1/', + 'rss' => 'http://purl.org/rss/1.0/', + ); + + // Get all namespaces declared in the feed element, with special handling + // for PHP versions prior to 5.1.2 as they don't handle namespaces. + $namespaces = version_compare(phpversion(), '5.1.2', '<') ? array() : $feed_XML->getNamespaces(TRUE); + + // Process the <rss:channel> resource containing feed metadata: + foreach ($feed_XML->children($canonical_namespaces['rss'])->channel as $rss_channel) { + $parsed_source = array( + 'title' => _parser_common_syndication_title((string) $rss_channel->title), + 'description' => (string) $rss_channel->description, + 'link' => (string) $rss_channel->link, + 'items' => array(), + ); + break; + } + + // Process each <rss:item> resource contained in the feed: + foreach ($feed_XML->children($canonical_namespaces['rss'])->item as $rss_item) { + + // Extract all available RDF statements from the feed item's RDF/XML + // tags, allowing for both the item's attributes and child elements to + // contain RDF properties: + $rdf_data = array(); + foreach ($namespaces as $ns => $ns_uri) { + // Note that we attempt to normalize the found property name + // namespaces to well-known 'standard' prefixes where possible, as the + // feed may in principle use any arbitrary prefixes and we should + // still be able to correctly handle it. + foreach ($rss_item->attributes($ns_uri) as $attr_name => $attr_value) { + $ns_prefix = ($ns_prefix = array_search($ns_uri, $canonical_namespaces)) ? $ns_prefix : $ns; + $rdf_data[$ns_prefix . ':' . $attr_name][] = (string) $attr_value; + } + foreach ($rss_item->children($ns_uri) as $rss_property) { + $ns_prefix = ($ns_prefix = array_search($ns_uri, $canonical_namespaces)) ? $ns_prefix : $ns; + $rdf_data[$ns_prefix . ':' . $rss_property->getName()][] = (string) $rss_property; + } + } + + // Declaratively define mappings that determine how to construct the result object. + $item = _parser_common_syndication_RDF10_item($rdf_data, array( + 'title' => array('rss:title', 'dc:title'), + 'description' => array('rss:description', 'dc:description', 'content:encoded'), + 'url' => array('rss:link', 'rdf:about'), + 'author_name' => array('dc:creator', 'dc:publisher'), + 'guid' => 'rdf:about', + 'timestamp' => 'dc:date', + 'tags' => 'dc:subject' + )); + + // Special handling for the title: + $item['title'] = _parser_common_syndication_title($item['title'], $item['description']); + + // Parse any date/time values into Unix timestamps: + $item['timestamp'] = _parser_common_syndication_parse_date($item['timestamp']); + + // If no GUID found, use the URL of the feed. + if (empty($item['guid'])) { + $item['guid'] = $item['url']; + } + + // Add every found RDF property to the feed item. + $item['rdf'] = array(); + foreach ($rdf_data as $rdf_property => $rdf_value) { + // looks nicer in the mapper UI + // @todo Revisit, not used with feedapi mapper anymore. + $rdf_property = str_replace(':', '_', $rdf_property); + $item['rdf'][$rdf_property] = $rdf_value; + } + + $parsed_source['items'][] = $item; + } + + return $parsed_source; +} + +function _parser_common_syndication_RDF10_property($rdf_data, $rdf_properties = array()) { + $rdf_properties = is_array($rdf_properties) ? $rdf_properties : array_slice(func_get_args(), 1); + foreach ($rdf_properties as $rdf_property) { + if ($rdf_property && !empty($rdf_data[$rdf_property])) { + // remove empty strings + return array_filter($rdf_data[$rdf_property], 'strlen'); + } + } +} + +function _parser_common_syndication_RDF10_item($rdf_data, $mappings) { + foreach ($mappings as $k => $v) { + $values = _parser_common_syndication_RDF10_property($rdf_data, $v); + $mappings[$k] = !is_array($values) || count($values) > 1 ? $values : reset($values); + } + return $mappings; +} + +/** + * Parse RSS2.0 feeds. + */ +function _parser_common_syndication_RSS20_parse($feed_XML) { + + $ns = array( + "content" => "http://purl.org/rss/1.0/modules/content/", + "dc" => "http://purl.org/dc/elements/1.1/", + "georss" => "http://www.georss.org/georss", + ); + + $parsed_source = array(); + // Detect the title. + $parsed_source['title'] = isset($feed_XML->channel->title) ? _parser_common_syndication_title("{$feed_XML->channel->title}") : ""; + // Detect the description. + $parsed_source['description'] = isset($feed_XML->channel->description) ? "{$feed_XML->channel->description}" : ""; + // Detect the link. + $parsed_source['link'] = isset($feed_XML->channel->link) ? "{$feed_XML->channel->link}" : ""; + $parsed_source['items'] = array(); + + foreach ($feed_XML->xpath('//item') as $news) { + $title = $body = $original_author = $original_url = $guid = ''; + + $category = $news->xpath('category'); + // Get children for current namespace. + if (version_compare(phpversion(), '5.1.2', '>')) { + $content = (array)$news->children($ns["content"]); + $dc = (array)$news->children($ns["dc"]); + $georss = (array)$news->children($ns["georss"]); + } + $news = (array) $news; + $news['category'] = $category; + + if (isset($news['title'])) { + $title = "{$news['title']}"; + } + + if (isset($news['description'])) { + $body = "{$news['description']}"; + } + // Some sources use content:encoded as description i.e. + // PostNuke PageSetter module. + if (isset($news['encoded'])) { // content:encoded for PHP < 5.1.2. + if (strlen($body) < strlen("{$news['encoded']}")) { + $body = "{$news['encoded']}"; + } + } + if (isset($content['encoded'])) { // content:encoded for PHP >= 5.1.2. + if (strlen($body) < strlen("{$content['encoded']}")) { + $body = "{$content['encoded']}"; + } + } + if (!isset($body)) { + $body = "{$news['title']}"; + } + + if (!empty($news['author'])) { + $original_author = "{$news['author']}"; + } + elseif (!empty($dc["creator"])) { + $original_author = (string)$dc["creator"]; + } + + if (!empty($news['link'])) { + $original_url = "{$news['link']}"; + $guid = $original_url; + } + + if (!empty($news['guid'])) { + $guid = "{$news['guid']}"; + } + + if (!empty($georss['featureName'])) { + $geoname = "{$georss['featureName']}"; + } + + $lat = + $lon = + $latlon = + $geoname = NULL; + if (!empty($georss['point'])) { + $latlon = explode(' ', $georss['point']); + $lat = "{$latlon[0]}"; + $lon = "{$latlon[1]}"; + if (!$geoname) { + $geoname = "$lat $lon"; + } + } + + $additional_taxonomies = array(); + $additional_taxonomies['RSS Categories'] = array(); + $additional_taxonomies['RSS Domains'] = array(); + if (isset($news['category'])) { + foreach ($news['category'] as $category) { + $additional_taxonomies['RSS Categories'][] = "{$category}"; + if (isset($category['domain'])) { + $domain = "{$category['domain']}"; + if (!empty($domain)) { + if (!isset($additional_taxonomies['RSS Domains'][$domain])) { + $additional_taxonomies['RSS Domains'][$domain] = array(); + } + $additional_taxonomies['RSS Domains'][$domain][] = count($additional_taxonomies['RSS Categories']) - 1; + } + } + } + } + + $item = array(); + $item['title'] = _parser_common_syndication_title($title, $body); + $item['description'] = $body; + $item['author_name'] = $original_author; + if (!empty($news['pubDate'])) { + $item['timestamp'] = _parser_common_syndication_parse_date($news['pubDate']); + } + elseif (!empty($dc['date'])) { + $item['timestamp'] = _parser_common_syndication_parse_date($dc['date']); + } + else { + $item['timestamp'] = time(); + } + $item['url'] = trim($original_url); + $item['guid'] = $guid; + + $item['geolocations'] = array(); + if (isset($geoname, $lat, $lon)) { + $item['geolocations'] = array( + array( + 'name' => $geoname, + 'lat' => $lat, + 'lon' => $lon, + ), + ); + } + + $item['domains'] = $additional_taxonomies['RSS Domains']; + $item['tags'] = $additional_taxonomies['RSS Categories']; + $parsed_source['items'][] = $item; + } + return $parsed_source; +} + +/** + * Parse a date comes from a feed. + * + * @param $date_string + * The date string in various formats. + * @return + * The timestamp of the string or the current time if can't be parsed + */ +function _parser_common_syndication_parse_date($date_str) { + // PHP < 5.3 doesn't like the GMT- notation for parsing timezones. + $date_str = str_replace("GMT-", "-", $date_str); + $date_str = str_replace("GMT+", "+", $date_str); + $parsed_date = strtotime($date_str); + if ($parsed_date === FALSE || $parsed_date == -1) { + $parsed_date = _parser_common_syndication_parse_w3cdtf($date_str); + } + return $parsed_date === FALSE ? time() : $parsed_date; +} + +/** + * Parse the W3C date/time format, a subset of ISO 8601. + * + * PHP date parsing functions do not handle this format. + * See http://www.w3.org/TR/NOTE-datetime for more information. + * Originally from MagpieRSS (http://magpierss.sourceforge.net/). + * + * @param $date_str + * A string with a potentially W3C DTF date. + * @return + * A timestamp if parsed successfully or FALSE if not. + */ +function _parser_common_syndication_parse_w3cdtf($date_str) { + if (preg_match('/(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(:(\d{2}))?(?:([-+])(\d{2}):?(\d{2})|(Z))?/', $date_str, $match)) { + list($year, $month, $day, $hours, $minutes, $seconds) = array($match[1], $match[2], $match[3], $match[4], $match[5], $match[6]); + // Calculate the epoch for current date assuming GMT. + $epoch = gmmktime($hours, $minutes, $seconds, $month, $day, $year); + if ($match[10] != 'Z') { // Z is zulu time, aka GMT + list($tz_mod, $tz_hour, $tz_min) = array($match[8], $match[9], $match[10]); + // Zero out the variables. + if (!$tz_hour) { + $tz_hour = 0; + } + if (!$tz_min) { + $tz_min = 0; + } + $offset_secs = (($tz_hour * 60) + $tz_min) * 60; + // Is timezone ahead of GMT? If yes, subtract offset. + if ($tz_mod == '+') { + $offset_secs *= -1; + } + $epoch += $offset_secs; + } + return $epoch; + } + else { + return FALSE; + } +} + +/** + * Extract the link that points to the original content (back to site or + * original article) + * + * @param $links + * Array of SimpleXML objects + */ +function _parser_common_syndication_link($links) { + $to_link = ''; + if (count($links) > 0) { + foreach ($links as $link) { + $link = $link->attributes(); + $to_link = isset($link["href"]) ? "{$link["href"]}" : ""; + if (isset($link["rel"])) { + if ("{$link["rel"]}" == 'alternate') { + break; + } + } + } + } + return $to_link; +} + +/** + * Prepare raw data to be a title + */ +function _parser_common_syndication_title($title, $body = FALSE) { + if (empty($title) && !empty($body)) { + // Explode to words and use the first 3 words. + $words = preg_split('/[\s,]+/', strip_tags($body)); + $title = implode(' ', array_slice($words, 0, 3)); + } + return $title; +} diff --git a/sites/all/modules/feeds/libraries/http_request.inc b/sites/all/modules/feeds/libraries/http_request.inc new file mode 100644 index 0000000000000000000000000000000000000000..44f4fb59c79deafd6c311d465963bc8cdc5254e9 --- /dev/null +++ b/sites/all/modules/feeds/libraries/http_request.inc @@ -0,0 +1,405 @@ +<?php + +/** + * @file + * Download via HTTP. + * + * Support caching, HTTP Basic Authentication, detection of RSS/Atom feeds, + * redirects. + */ + +/** + * PCRE for finding the link tags in html. + */ +define('HTTP_REQUEST_PCRE_LINK_TAG', '/<link((?:[\x09\x0A\x0B\x0C\x0D\x20]+[^\x09\x0A\x0B\x0C\x0D\x20\x2F\x3E][^\x09\x0A\x0B\x0C\x0D\x20\x2F\x3D\x3E]*(?:[\x09\x0A\x0B\x0C\x0D\x20]*=[\x09\x0A\x0B\x0C\x0D\x20]*(?:"(?:[^"]*)"|\'(?:[^\']*)\'|(?:[^\x09\x0A\x0B\x0C\x0D\x20\x22\x27\x3E][^\x09\x0A\x0B\x0C\x0D\x20\x3E]*)?))?)*)[\x09\x0A\x0B\x0C\x0D\x20]*(>(.*)<\/link>|(\/)?>)/si'); + +/** + * PCRE for matching all the attributes in a tag. + */ +define('HTTP_REQUEST_PCRE_TAG_ATTRIBUTES', '/[\x09\x0A\x0B\x0C\x0D\x20]+([^\x09\x0A\x0B\x0C\x0D\x20\x2F\x3E][^\x09\x0A\x0B\x0C\x0D\x20\x2F\x3D\x3E]*)(?:[\x09\x0A\x0B\x0C\x0D\x20]*=[\x09\x0A\x0B\x0C\x0D\x20]*(?:"([^"]*)"|\'([^\']*)\'|([^\x09\x0A\x0B\x0C\x0D\x20\x22\x27\x3E][^\x09\x0A\x0B\x0C\x0D\x20\x3E]*)?))?/'); + +/** + * For cUrl specific errors. + */ +class HRCurlException extends Exception {} + +/** + * Discover RSS or atom feeds at the given URL. If document in given URL is an + * HTML document, function attempts to discover RSS or Atom feeds. + * + * @param string $url + * The url of the feed to retrieve. + * @param array $settings + * An optional array of settings. Valid options are: accept_invalid_cert. + * + * @return bool|string + * The discovered feed, or FALSE if the URL is not reachable or there was an + * error. + */ +function http_request_get_common_syndication($url, $settings = NULL) { + + $accept_invalid_cert = isset($settings['accept_invalid_cert']) ? $settings['accept_invalid_cert'] : FALSE; + $download = http_request_get($url, NULL, NULL, $accept_invalid_cert); + + // Cannot get the feed, return. + // http_request_get() always returns 200 even if its 304. + if ($download->code != 200) { + return FALSE; + } + + // Drop the data into a seperate variable so all manipulations of the html + // will not effect the actual object that exists in the static cache. + // @see http_request_get. + $downloaded_string = $download->data; + // If this happens to be a feed then just return the url. + if (http_request_is_feed($download->headers['content-type'], $downloaded_string)) { + return $url; + } + + $discovered_feeds = http_request_find_feeds($downloaded_string); + foreach ($discovered_feeds as $feed_url) { + $absolute = http_request_create_absolute_url($feed_url, $url); + if (!empty($absolute)) { + // @TODO: something more intelligent? + return $absolute; + } + } +} + +/** + * Get the content from the given URL. + * + * @param string $url + * A valid URL (not only web URLs). + * @param string $username + * If the URL uses authentication, supply the username. + * @param string $password + * If the URL uses authentication, supply the password. + * @param bool $accept_invalid_cert + * Whether to accept invalid certificates. + + * @return stdClass + * An object that describes the data downloaded from $url. + */ +function http_request_get($url, $username = NULL, $password = NULL, $accept_invalid_cert = FALSE) { + // Intra-pagedownload cache, avoid to download the same content twice within + // one page download (it's possible, compatible and parse calls). + static $download_cache = array(); + if (isset($download_cache[$url])) { + return $download_cache[$url]; + } + + if (!$username && valid_url($url, TRUE)) { + // Handle password protected feeds. + $url_parts = parse_url($url); + if (!empty($url_parts['user'])) { + $password = $url_parts['pass']; + $username = $url_parts['user']; + } + } + + $curl = http_request_use_curl(); + + // Only download and parse data if really needs refresh. + // Based on "Last-Modified" and "If-Modified-Since". + $headers = array(); + if ($cache = cache_get('feeds_http_download_' . md5($url))) { + $last_result = $cache->data; + $last_headers = array_change_key_case($last_result->headers); + + if (!empty($last_headers['etag'])) { + if ($curl) { + $headers[] = 'If-None-Match: ' . $last_headers['etag']; + } + else { + $headers['If-None-Match'] = $last_headers['etag']; + } + } + if (!empty($last_headers['last-modified'])) { + if ($curl) { + $headers[] = 'If-Modified-Since: ' . $last_headers['last-modified']; + } + else { + $headers['If-Modified-Since'] = $last_headers['last-modified']; + } + } + if (!empty($username) && !$curl) { + $headers['Authorization'] = 'Basic ' . base64_encode("$username:$password"); + } + } + + // Support the 'feed' and 'webcal' schemes by converting them into 'http'. + $url = strtr($url, array('feed://' => 'http://', 'webcal://' => 'http://')); + + if ($curl) { + $headers[] = 'User-Agent: Drupal (+http://drupal.org/)'; + $result = new stdClass(); + + // Parse the URL and make sure we can handle the schema. + // cURL can only support either http:// or https://. + // CURLOPT_PROTOCOLS is only supported with cURL 7.19.4 + $uri = parse_url($url); + if (!isset($uri['scheme'])) { + $result->error = 'missing schema'; + $result->code = -1002; + } + else { + switch ($uri['scheme']) { + case 'http': + case 'https': + // Valid scheme. + break; + default: + $result->error = 'invalid schema ' . $uri['scheme']; + $result->code = -1003; + break; + } + } + + // If the scheme was valid, continue to request the feed using cURL. + if (empty($result->error)) { + $download = curl_init($url); + curl_setopt($download, CURLOPT_FOLLOWLOCATION, TRUE); + if (!empty($username)) { + curl_setopt($download, CURLOPT_USERPWD, "{$username}:{$password}"); + curl_setopt($download, CURLOPT_HTTPAUTH, CURLAUTH_ANY); + } + curl_setopt($download, CURLOPT_HTTPHEADER, $headers); + curl_setopt($download, CURLOPT_HEADER, TRUE); + curl_setopt($download, CURLOPT_RETURNTRANSFER, TRUE); + curl_setopt($download, CURLOPT_ENCODING, ''); + curl_setopt($download, CURLOPT_TIMEOUT, variable_get('http_request_timeout', 30)); + if ($accept_invalid_cert) { + curl_setopt($download, CURLOPT_SSL_VERIFYPEER, 0); + } + $header = ''; + $data = curl_exec($download); + if (curl_error($download)) { + throw new HRCurlException( + t('cURL error (@code) @error for @url', array( + '@code' => curl_errno($download), + '@error' => curl_error($download), + '@url' => $url + )), curl_errno($download) + ); + } + + $header_size = curl_getinfo($download, CURLINFO_HEADER_SIZE); + $header = substr($data, 0, $header_size - 1); + $result->data = substr($data, $header_size); + $headers = preg_split("/(\r\n){2}/", $header); + $header_lines = preg_split("/\r\n|\n|\r/", end($headers)); + $result->headers = array(); + array_shift($header_lines); // skip HTTP response status + + while ($line = trim(array_shift($header_lines))) { + list($header, $value) = explode(':', $line, 2); + // Normalize the headers. + $header = strtolower($header); + + if (isset($result->headers[$header]) && $header == 'set-cookie') { + // RFC 2109: the Set-Cookie response header comprises the token Set- + // Cookie:, followed by a comma-separated list of one or more cookies. + $result->headers[$header] .= ',' . trim($value); + } + else { + $result->headers[$header] = trim($value); + } + } + $result->code = curl_getinfo($download, CURLINFO_HTTP_CODE); + + curl_close($download); + } + } + else { + $result = drupal_http_request($url, array('headers' => $headers, 'timeout' => variable_get('http_request_timeout', 30))); + } + + $result->code = isset($result->code) ? $result->code : 200; + + // In case of 304 Not Modified try to return cached data. + if ($result->code == 304) { + + if (isset($last_result)) { + $last_result->from_cache = TRUE; + return $last_result; + } + else { + // It's a tragedy, this file must exist and contain good data. + // In this case, clear cache and repeat. + cache_clear_all('feeds_http_download_' . md5($url), 'cache'); + return http_request_get($url, $username, $password); + } + } + + // Set caches. + cache_set('feeds_http_download_' . md5($url), $result); + $download_cache[$url] = $result; + + return $result; +} + +/** + * Decides if it's possible to use cURL or not. + * + * @return bool + * TRUE if curl is available, FALSE otherwise. + */ +function http_request_use_curl() { + // Allow site administrators to choose to not use cURL. + if (variable_get('feeds_never_use_curl', FALSE)) { + return FALSE; + } + + // Check availability of cURL on the system. + $basedir = ini_get("open_basedir"); + return function_exists('curl_init') && !ini_get('safe_mode') && empty($basedir); +} + +/** + * Clear cache for a specific URL. + */ +function http_request_clear_cache($url) { + cache_clear_all('feeds_http_download_' . md5($url), 'cache'); +} + +/** + * Returns if the provided $content_type is a feed. + * + * @param string $content_type + * The Content-Type header. + * + * @param string $data + * The actual data from the http request. + * + * @return bool + * Returns TRUE if this is a parsable feed. + */ +function http_request_is_feed($content_type, $data) { + $pos = strpos($content_type, ';'); + if ($pos !== FALSE) { + $content_type = substr($content_type, 0, $pos); + } + $content_type = strtolower($content_type); + if (strpos($content_type, 'xml') !== FALSE) { + return TRUE; + } + + // @TODO: Sometimes the content-type can be text/html but still be a valid + // feed. + return FALSE; +} + +/** + * Finds potential feed tags in the HTML document. + * + * @param string $html + * The html string to search. + * + * @return array + * An array of href to feeds. + */ +function http_request_find_feeds($html) { + $matches = array(); + preg_match_all(HTTP_REQUEST_PCRE_LINK_TAG, $html, $matches); + $links = $matches[1]; + $valid_links = array(); + + // Build up all the links information. + foreach ($links as $link_tag) { + $attributes = array(); + $candidate = array(); + + preg_match_all(HTTP_REQUEST_PCRE_TAG_ATTRIBUTES, $link_tag, $attributes, PREG_SET_ORDER); + foreach ($attributes as $attribute) { + // Find the key value pairs, attribute[1] is key and attribute[2] is the + // value. + if (!empty($attribute[1]) && !empty($attribute[2])) { + $candidate[drupal_strtolower($attribute[1])] = drupal_strtolower(decode_entities($attribute[2])); + } + } + + // Examine candidate to see if it s a feed. + // @TODO: could/should use http_request_is_feed ?? + if (isset($candidate['rel']) && $candidate['rel'] == 'alternate') { + if (isset($candidate['href']) && isset($candidate['type']) && strpos($candidate['type'], 'xml') !== FALSE) { + // All tests pass, its a valid candidate. + $valid_links[] = $candidate['href']; + } + } + } + + return $valid_links; +} + +/** + * Create an absolute url. + * + * @param string $url + * The href to transform. + * @param string $base_url + * The url to be used as the base for a relative $url. + * + * @return string + * An absolute url + */ +function http_request_create_absolute_url($url, $base_url) { + $url = trim($url); + if (valid_url($url, TRUE)) { + // Valid absolute url already. + return $url; + } + + // Turn relative url into absolute. + if (valid_url($url, FALSE)) { + // Produces variables $scheme, $host, $user, $pass, $path, $query and + // $fragment. + $parsed_url = parse_url($base_url); + + $path = dirname($parsed_url['path']); + + // Adding to the existing path. + if ($url{0} == '/') { + $cparts = array_filter(explode("/", $url)); + } + else { + // Backtracking from the existing path. + $cparts = array_merge(array_filter(explode("/", $path)), array_filter(explode("/", $url))); + foreach ($cparts as $i => $part) { + if ($part == '.') { + $cparts[$i] = NULL; + } + if ($part == '..') { + $cparts[$i - 1] = NULL; + $cparts[$i] = NULL; + } + } + $cparts = array_filter($cparts); + } + $path = implode("/", $cparts); + + // Build the prefix to the path. + $absolute_url = ''; + if (isset($parsed_url['scheme'])) { + $absolute_url = $parsed_url['scheme'] . '://'; + } + + if (isset($parsed_url['user'])) { + $absolute_url .= $parsed_url['user']; + if (isset($pass)) { + $absolute_url .= ':' . $parsed_url['pass']; + } + $absolute_url .= '@'; + } + if (isset($parsed_url['host'])) { + $absolute_url .= $parsed_url['host'] . '/'; + } + + $absolute_url .= $path; + + if (valid_url($absolute_url, TRUE)) { + return $absolute_url; + } + } + return FALSE; +} diff --git a/sites/all/modules/feeds/libraries/opml_parser.inc b/sites/all/modules/feeds/libraries/opml_parser.inc new file mode 100644 index 0000000000000000000000000000000000000000..d1faefcc127925b46827c5b79d677b11ba723db7 --- /dev/null +++ b/sites/all/modules/feeds/libraries/opml_parser.inc @@ -0,0 +1,46 @@ +<?php + +/** + * @file + * OPML Parser. + */ + +/** + * Parse OPML file. + * + * @param $raw + * File contents. + * @return + * An array of the parsed OPML file. + */ +function opml_parser_parse($raw) { + $feeds = $items = array(); + $xml = @ new SimpleXMLElement($raw); + $feeds['title'] = (string)current($xml->xpath('//head/title')); + + // @todo Make xpath case insensitive. + $outlines = $xml->xpath('//outline[@xmlUrl]'); + foreach ($outlines as $outline) { + $item = array(); + foreach ($outline->attributes() as $k => $v) { + if (in_array(strtolower($k), array('title', 'text', 'xmlurl'))) { + $item[strtolower($k)] = (string) $v; + } + } + + // If no title, forge it from text. + if (!isset($item['title']) && isset($item['text'])) { + if (strlen($item['text']) < 40) { + $item['title'] = $item['text']; + } + else { + $item['title'] = trim(substr($item['text'], 0, 30)) . ' ...'; + } + } + if (isset($item['title']) && isset($item['xmlurl'])) { + $items[] = $item; + } + } + $feeds['items'] = $items; + return $feeds; +} diff --git a/sites/all/modules/feeds/mappers/date.inc b/sites/all/modules/feeds/mappers/date.inc new file mode 100644 index 0000000000000000000000000000000000000000..3de6a69f2e5af4c15c9df1c2ec86a466adfcc940 --- /dev/null +++ b/sites/all/modules/feeds/mappers/date.inc @@ -0,0 +1,62 @@ +<?php + +/** + * @file + * On behalf implementation of Feeds mapping API for date + */ + +/** + * Implements hook_feeds_processor_targets_alter(). + * + * @see FeedsNodeProcessor::getMappingTargets(). + * + * @todo Only provides "end date" target if field allows it. + */ +function date_feeds_processor_targets_alter(&$targets, $entity_type, $bundle_name) { + foreach (field_info_instances($entity_type, $bundle_name) as $name => $instance) { + $info = field_info_field($name); + if (in_array($info['type'], array('date', 'datestamp', 'datetime'))) { + $targets[$name . ':start'] = array( + 'name' => t('@name: Start', array('@name' => $instance['label'])), + 'callback' => 'date_feeds_set_target', + 'description' => t('The start date for the @name field. Also use if mapping both start and end.', array('@name' => $instance['label'])), + 'real_target' => $name, + ); + $targets[$name . ':end'] = array( + 'name' => t('@name: End', array('@name' => $instance['label'])), + 'callback' => 'date_feeds_set_target', + 'description' => t('The end date for the @name field.', array('@name' => $instance['label'])), + 'real_target' => $name, + ); + } + } +} + +/** + * Implements hook_feeds_set_target(). + * + * @param $node + * The target node. + * @param $field_name + * The name of field on the target node to map to. + * @param $feed_element + * The value to be mapped. Should be either a (flexible) date string + * or a FeedsDateTimeElement object. + * + * @todo Support array of values for dates. + */ +function date_feeds_set_target($source, $entity, $target, $feed_element) { + list($field_name, $sub_field) = explode(':', $target, 2); + if (!($feed_element instanceof FeedsDateTimeElement)) { + if (is_array($feed_element)) { + $feed_element = $feed_element[0]; + } + if ($sub_field == 'end') { + $feed_element = new FeedsDateTimeElement(NULL, $feed_element); + } + else { + $feed_element = new FeedsDateTimeElement($feed_element, NULL); + } + } + $feed_element->buildDateField($entity, $field_name); +} diff --git a/sites/all/modules/feeds/mappers/file.inc b/sites/all/modules/feeds/mappers/file.inc new file mode 100644 index 0000000000000000000000000000000000000000..2cc5dc3a6eb27f139f3bb892afd5c87ac6f06a4b --- /dev/null +++ b/sites/all/modules/feeds/mappers/file.inc @@ -0,0 +1,94 @@ +<?php + +/** + * @file + * On behalf implementation of Feeds mapping API for file.module and + * image.module. + * + * Does actually not include mappers for field types defined in fields module + * (because there aren't any) but mappers for all fields that contain their + * value simply in $entity->fieldname['und'][$i]['value']. + */ + +/** + * Implements hook_feeds_processor_targets_alter(). + * + * @see FeedsNodeProcessor::getMappingTargets(). + */ +function file_feeds_processor_targets_alter(&$targets, $entity_type, $bundle_name) { + foreach (field_info_instances($entity_type, $bundle_name) as $name => $instance) { + $info = field_info_field($name); + + if (in_array($info['type'], array('file', 'image'))) { + $targets[$name] = array( + 'name' => check_plain($instance['label']), + 'callback' => 'file_feeds_set_target', + 'description' => t('The @label field of the node.', array('@label' => $instance['label'])), + ); + } + } +} + +/** + * Callback for mapping. Here is where the actual mapping happens. + * + * When the callback is invoked, $target contains the name of the field the + * user has decided to map to and $value contains the value of the feed item + * element the user has picked as a source. + */ +function file_feeds_set_target($source, $entity, $target, $value) { + if (empty($value)) { + return; + } + module_load_include('inc', 'file'); + + // Make sure $value is an array of objects of type FeedsEnclosure. + if (!is_array($value)) { + $value = array($value); + } + foreach ($value as $k => $v) { + if (!($v instanceof FeedsEnclosure)) { + if (is_string($v)) { + $value[$k] = new FeedsEnclosure($v, file_get_mimetype($v)); + } + else { + unset($value[$k]); + } + } + } + if (empty($value)) { + return; + } + + // Determine file destination. + // @todo This needs review and debugging. + list($entity_id, $vid, $bundle_name) = entity_extract_ids($entity->feeds_item->entity_type, $entity); + $instance_info = field_info_instance($entity->feeds_item->entity_type, $target, $bundle_name); + $info = field_info_field($target); + $data = array(); + if (!empty($entity->uid)) { + $data[$entity->feeds_item->entity_type] = $entity; + } + $destination = file_field_widget_uri($info, $instance_info, $data); + + // Populate entity. + $i = 0; + $field = isset($entity->$target) ? $entity->$target : array(); + foreach ($value as $v) { + try { + $file = $v->getFile($destination); + } + catch (Exception $e) { + watchdog_exception('Feeds', $e, nl2br(check_plain($e))); + } + if ($file) { + $field['und'][$i] = (array)$file; + $field['und'][$i]['display'] = 1; // @todo: Figure out how to properly populate this field. + if ($info['cardinality'] == 1) { + break; + } + $i++; + } + } + $entity->{$target} = $field; +} diff --git a/sites/all/modules/feeds/mappers/link.inc b/sites/all/modules/feeds/mappers/link.inc new file mode 100644 index 0000000000000000000000000000000000000000..45a86f7dc7f651ee2914f1bd9318472ed41be127 --- /dev/null +++ b/sites/all/modules/feeds/mappers/link.inc @@ -0,0 +1,76 @@ +<?php + +/** + * @file + * On behalf implementation of Feeds mapping API for link.module. + */ + +/** + * Implements hook_feeds_processor_targets_alter(). + * + * @see FeedsProcessor::getMappingTargets() + */ +function link_feeds_processor_targets_alter(&$targets, $entity_type, $bundle_name) { + foreach (field_info_instances($entity_type, $bundle_name) as $name => $instance) { + $info = field_info_field($name); + if ($info['type'] == 'link_field') { + if (array_key_exists('url', $info['columns'])) { + $targets[$name . ':url'] = array( + 'name' => t('@name: URL', array('@name' => $instance['label'])), + 'callback' => 'link_feeds_set_target', + 'description' => t('The @label field of the entity.', array('@label' => $instance['label'])), + 'real_target' => $name, + ); + } + if (array_key_exists('title', $info['columns'])) { + $targets[$name . ':title'] = array( + 'name' => t('@name: Title', array('@name' => $instance['label'])), + 'callback' => 'link_feeds_set_target', + 'description' => t('The @label field of the entity.', array('@label' => $instance['label'])), + 'real_target' => $name, + ); + } + } + } +} + +/** + * Callback for mapping. Here is where the actual mapping happens. + * + * When the callback is invoked, $target contains the name of the field the + * user has decided to map to and $value contains the value of the feed item + * element the user has picked as a source. + */ +function link_feeds_set_target($source, $entity, $target, $value) { + if (empty($value)) { + return; + } + + // Handle non-multiple value fields. + if (!is_array($value)) { + $value = array($value); + } + + // Iterate over all values. + list($field_name, $column) = explode(':', $target); + $info = field_info_field($field_name); + + $field = isset($entity->$field_name) ? $entity->$field_name : array(); + $delta = 0; + + foreach ($value as $v) { + if ($info['cardinality'] == $delta) { + break; + } + + if (is_object($v) && ($v instanceof FeedsElement)) { + $v = $v->getValue(); + } + + if (is_scalar($v)) { + $field['und'][$delta][$column] = $v; + $delta++; + } + } + $entity->$field_name = $field; +} diff --git a/sites/all/modules/feeds/mappers/number.inc b/sites/all/modules/feeds/mappers/number.inc new file mode 100644 index 0000000000000000000000000000000000000000..b64c4ee4c7356e531aecdc414163505daad6ec81 --- /dev/null +++ b/sites/all/modules/feeds/mappers/number.inc @@ -0,0 +1,74 @@ +<?php + +/** + * @file + * On behalf implementation of Feeds mapping API for number.module. + */ + +/** + * Implements hook_feeds_processor_targets_alter(). + * + * @see FeedsProcessor::getMappingTargets() + */ +function number_feeds_processor_targets_alter(&$targets, $entity_type, $bundle_name) { + $numeric_types = array( + 'list_integer', + 'list_float', + 'list_boolean', + 'number_integer', + 'number_decimal', + 'number_float', + ); + foreach (field_info_instances($entity_type, $bundle_name) as $name => $instance) { + $info = field_info_field($name); + + if (in_array($info['type'], $numeric_types)) { + $targets[$name] = array( + 'name' => check_plain($instance['label']), + 'callback' => 'number_feeds_set_target', + 'description' => t('The @label field of the entity.', array('@label' => $instance['label'])), + ); + } + } +} + +/** + * Callback for mapping numerics. + * + * Ensure that $value is a numeric to avoid database errors. + */ +function number_feeds_set_target($source, $entity, $target, $value) { + + // Do not perform the regular empty() check here. 0 is a valid value. That's + // really just a performance thing anyway. + + if (!is_array($value)) { + $value = array($value); + } + + $info = field_info_field($target); + + // Iterate over all values. + $field = isset($entity->$target) ? $entity->$target : array('und' => array()); + + // Allow for multiple mappings to the same target. + $delta = count($field['und']); + + foreach ($value as $v) { + + if ($info['cardinality'] == $delta) { + break; + } + + if (is_object($v) && ($v instanceof FeedsElement)) { + $v = $v->getValue(); + } + + if (is_numeric($v)) { + $field['und'][$delta]['value'] = $v; + $delta++; + } + } + + $entity->$target = $field; +} diff --git a/sites/all/modules/feeds/mappers/path.inc b/sites/all/modules/feeds/mappers/path.inc new file mode 100644 index 0000000000000000000000000000000000000000..47ae0fcf6475b19e544caad21519d1ee5d8fa118 --- /dev/null +++ b/sites/all/modules/feeds/mappers/path.inc @@ -0,0 +1,120 @@ +<?php + +/** + * @file + * On behalf implementation of Feeds mapping API for path.module. + */ + +/** + * Implements hook_feeds_processor_targets_alter(). + * + * @see FeedsNodeProcessor::getMappingTargets(). + */ +function path_feeds_processor_targets_alter(&$targets, $entity_type, $bundle_name) { + switch ($entity_type) { + case 'node': + case 'taxonomy_term': + case 'user': + $targets['path_alias'] = array( + 'name' => t('Path alias'), + 'description' => t('URL path alias of the node.'), + 'callback' => 'path_feeds_set_target', + 'summary_callback' => 'path_feeds_summary_callback', + 'form_callback' => 'path_feeds_form_callback', + ); + break; + } +} + +/** + * Callback for mapping. Here is where the actual mapping happens. + * + * When the callback is invoked, $target contains the name of the field the + * user has decided to map to and $value contains the value of the feed item + * element the user has picked as a source. + */ +function path_feeds_set_target($source, $entity, $target, $value, $mapping) { + if (empty($value)) { + $value = ''; + } + + // Path alias cannot be multi-valued, so use the first value. + if (is_array($value)) { + $value = $value[0]; + } + + $entity->path = array(); + + $entity_type = $source->importer->processor->entityType(); + + list($id, , ) = entity_extract_ids($entity_type, $entity); + + if ($id) { + $uri = entity_uri($entity_type, $entity); + + // Check for existing aliases. + if ($path = path_load($uri['path'])) { + $entity->path = $path; + } + } + + $entity->path['pathauto'] = FALSE; + // Allow pathauto to set the path alias if the option is set, and this value + // is empty. + if (!empty($mapping['pathauto_override']) && !$value) { + $entity->path['pathauto'] = TRUE; + } + else { + $entity->path['alias'] = ltrim($value, '/'); + } +} + +/** + * Mapping configuration summary for path.module. + * + * @param $mapping + * Associative array of the mapping settings. + * @param $target + * Array of target settings, as defined by the processor or + * hook_feeds_processor_targets_alter(). + * @param $form + * The whole mapping form. + * @param $form_state + * The form state of the mapping form. + * + * @return + * Returns, as a string that may contain HTML, the summary to display while + * the full form isn't visible. + * If the return value is empty, no summary and no option to view the form + * will be displayed. + */ +function path_feeds_summary_callback($mapping, $target, $form, $form_state) { + if (!module_exists('pathauto')) { + return; + } + + if (empty($mapping['pathauto_override'])) { + return t('Do not allow Pathauto if empty.'); + } + + else { + return t('Allow Pathauto if empty.'); + } +} + +/** + * Settings form callback. + * + * @return + * The per mapping configuration form. Once the form is saved, $mapping will + * be populated with the form values. + */ +function path_feeds_form_callback($mapping, $target, $form, $form_state) { + return array( + 'pathauto_override' => array( + '#type' => 'checkbox', + '#title' => t('Allow Pathauto to set the alias if the value is empty.'), + '#default_value' => !empty($mapping['pathauto_override']), + ), + ); +} diff --git a/sites/all/modules/feeds/mappers/profile.inc b/sites/all/modules/feeds/mappers/profile.inc new file mode 100644 index 0000000000000000000000000000000000000000..0dd62dea4af8e0d11174849aebbc1b73b72b27ea --- /dev/null +++ b/sites/all/modules/feeds/mappers/profile.inc @@ -0,0 +1,35 @@ +<?php + +/** + * @file + * On behalf implementation of Feeds mapping API for user profiles. + */ + +/** + * Implements hook_feeds_processor_target_alter(). + */ +function profile_feeds_processor_targets_alter(&$targets, $entity_type, $bundle_name) { + + if ($entity_type != 'user') { + return; + } + + $categories = profile_user_categories(); + + foreach ($categories as $category) { + foreach (_profile_get_fields($category['name']) as $record) { + $targets[$record->name] = array( + 'name' => t('Profile: @name', array('@name' => $record->title)), + 'description' => t('Profile: @name', array('@name' => $record->title)), + 'callback' => 'profile_feeds_set_target', + ); + } + } +} + +/** + * Set the user profile target after import. + */ +function profile_feeds_set_target($source, $entity, $target, $value, $mapping) { + $entity->$target = $value; +} diff --git a/sites/all/modules/feeds/mappers/taxonomy.inc b/sites/all/modules/feeds/mappers/taxonomy.inc new file mode 100644 index 0000000000000000000000000000000000000000..201dff66faa924e76314ecf989499e24b71b5671 --- /dev/null +++ b/sites/all/modules/feeds/mappers/taxonomy.inc @@ -0,0 +1,186 @@ +<?php + +/** + * @file + * Mapper that exposes a node's taxonomy vocabularies as mapping targets. + */ + +/** + * Implements hook_feeds_parser_sources_alter(). + * + * @todo: Upgrade to 7. + */ +function taxonomy_feeds_parser_sources_alter(&$sources, $content_type) { + if (!empty($content_type)) { + foreach (taxonomy_get_vocabularies($content_type) as $vocabulary) { + $sources['parent:taxonomy:' . $vocabulary->machine_name] = array( + 'name' => t('Feed node: Taxonomy: @vocabulary', array('@vocabulary' => $vocabulary->name)), + 'description' => t('Taxonomy terms from feed node in given vocabulary.'), + 'callback' => 'taxonomy_feeds_get_source', + ); + } + } +} + +/** + * Callback, returns taxonomy from feed node. + */ +function taxonomy_feeds_get_source(FeedsSource $source, FeedsParserResult $result, $key) { + if ($node = node_load($source->feed_nid)) { + $terms = taxonomy_feeds_node_get_terms($node); + $vocabularies = taxonomy_vocabulary_load_multiple(array(), array('machine_name' => str_replace('parent:taxonomy:', '', $key))); + $vocabulary = array_shift($vocabularies); + $result = array(); + foreach ($terms as $tid => $term) { + if ($term->vid == $vocabulary->vid) { + $result[] = new FeedsTermElement($term); + } + } + return $result; + } +} + +/** + * Implements hook_feeds_processor_targets_alter(). + */ +function taxonomy_feeds_processor_targets_alter(&$targets, $entity_type, $bundle_name) { + foreach (field_info_instances($entity_type, $bundle_name) as $name => $instance) { + $info = field_info_field($name); + if ($info['type'] == 'taxonomy_term_reference') { + $targets[$name] = array( + 'name' => check_plain($instance['label']), + 'callback' => 'taxonomy_feeds_set_target', + 'description' => t('The @label field of the node.', array('@label' => $instance['label'])), + ); + } + } +} + +/** + * Callback for mapping. Here is where the actual mapping happens. + * + * @todo Do not create new terms for non-autotag fields. + */ +function taxonomy_feeds_set_target($source, $entity, $target, $terms) { + if (empty($terms)) { + return; + } + + // Handle non-multiple values. + if (!is_array($terms)) { + $terms = array($terms); + } + + $info = field_info_field($target); + + // See http://drupal.org/node/881530 + if (isset($info['settings']['allowed_values'][0]['vocabulary'])) { + $vocabulary = taxonomy_vocabulary_machine_name_load($info['settings']['allowed_values'][0]['vocabulary']); + } + else { + $vocabulary = taxonomy_vocabulary_load($info['settings']['allowed_values'][0]['vid']); + } + + $i = 0; + $entity->$target = isset($entity->$target) ? $entity->$target : array(); + foreach ($terms as $term) { + $tid = 0; + if ($term instanceof FeedsTermElement) { + $tid = $term->tid; + } + elseif (is_numeric($term)) { + $tid = $term; + } + elseif (is_string($term)) { + $tid = taxonomy_term_check_term($term, $vocabulary->vid); + } + if ($tid) { + $entity->{$target}['und'][$i]['tid'] = $tid; + } + + if ($info['cardinality'] == 1) { + break; + } + $i++; + } +} + +/** + * Find all terms associated with the given node, within one vocabulary. + */ +function taxonomy_feeds_node_get_terms($node, $key = 'tid') { + $terms = &drupal_static(__FUNCTION__); + + if (!isset($terms[$node->nid][$key])) { + // Get tids from all taxonomy_term_reference fields. + $tids = array(); + $fields = field_info_fields(); + foreach ($fields as $field_name => $field) { + if ($field['type'] == 'taxonomy_term_reference' && field_info_instance('node', $field_name, $node->type)) { + if (($items = field_get_items('node', $node, $field_name)) && is_array($items)) { + $tids = array_merge($tids, array_map('_taxonomy_extract_tid', $items)); + } + } + } + + // Load terms and cache them in static var. + $curr_terms = taxonomy_term_load_multiple($tids); + $terms[$node->nid][$key] = array(); + foreach ($curr_terms as $term) { + $terms[$node->nid][$key][$term->$key] = $term; + } + } + return $terms[$node->nid][$key]; +} + +/** + * Helper function used in taxonomy_feeds_node_get_terms(). Extracts + * tid from array item returned by field_get_items(). + * + * @param $item tid information in a form of single element array (key == 'tid', value == tid we're looking for) + * + * @return tid extracted from $item. + * + * @see taxonomy_feeds_node_get_terms() + * @see field_get_items() + */ +function _taxonomy_extract_tid($item) { + return $item['tid']; +} + +/** + * Checks whether a term identified by name and vocabulary exists. Creates a + * new term if it does not exist. + * + * @param $name + * A term name. + * @param $vid + * A vocabulary id. + * + * @return + * A term id. + */ +function taxonomy_term_check_term($name, $vid) { + $name = trim($name); + $term = taxonomy_term_lookup_term($name, $vid); + if (empty($term)) { + $term = new stdClass(); + $term->name = $name; + $term->vid = $vid; + taxonomy_term_save($term); + return $term->tid; + } + return $term->tid; +} + +/** + * Looks up a term, assumes SQL storage backend. + */ +function taxonomy_term_lookup_term($name, $vid) { + return db_select('taxonomy_term_data', 'td') + ->fields('td', array('tid', 'name')) + ->condition('name', $name) + ->condition('vid', $vid) + ->execute() + ->fetchObject(); +} \ No newline at end of file diff --git a/sites/all/modules/feeds/mappers/text.inc b/sites/all/modules/feeds/mappers/text.inc new file mode 100644 index 0000000000000000000000000000000000000000..48447d7b7a68b53b566d4cad828aa16909c9dfc6 --- /dev/null +++ b/sites/all/modules/feeds/mappers/text.inc @@ -0,0 +1,79 @@ +<?php + +/** + * @file + * On behalf implementation of Feeds mapping API for text.module. + */ + +/** + * Implements hook_feeds_processor_targets_alter(). + * + * @see FeedsProcessor::getMappingTargets() + */ +function text_feeds_processor_targets_alter(&$targets, $entity_type, $bundle_name) { + $text_types = array( + 'list_text', + 'text', + 'text_long', + 'text_with_summary', + ); + foreach (field_info_instances($entity_type, $bundle_name) as $name => $instance) { + $info = field_info_field($name); + + if (in_array($info['type'], $text_types)) { + $targets[$name] = array( + 'name' => check_plain($instance['label']), + 'callback' => 'text_feeds_set_target', + 'description' => t('The @label field of the entity.', array('@label' => $instance['label'])), + ); + } + } +} + +/** + * Callback for mapping text fields. + */ +function text_feeds_set_target($source, $entity, $target, $value) { + if (empty($value)) { + return; + } + + if (!is_array($value)) { + $value = array($value); + } + + if (isset($source->importer->processor->config['input_format'])) { + $format = $source->importer->processor->config['input_format']; + } + + $info = field_info_field($target); + + // Iterate over all values. + $field = isset($entity->$target) ? $entity->$target : array('und' => array()); + + // Allow for multiple mappings to the same target. + $delta = count($field['und']); + + foreach ($value as $v) { + + if ($info['cardinality'] == $delta) { + break; + } + + if (is_object($v) && ($v instanceof FeedsElement)) { + $v = $v->getValue(); + } + + if (is_scalar($v)) { + $field['und'][$delta]['value'] = $v; + + if (isset($format)) { + $field['und'][$delta]['format'] = $format; + } + + $delta++; + } + } + + $entity->$target = $field; +} diff --git a/sites/all/modules/feeds/plugins/FeedsCSVParser.inc b/sites/all/modules/feeds/plugins/FeedsCSVParser.inc new file mode 100644 index 0000000000000000000000000000000000000000..337dd6834b54fc87b9c8ec270f503afaf64b4378 --- /dev/null +++ b/sites/all/modules/feeds/plugins/FeedsCSVParser.inc @@ -0,0 +1,216 @@ +<?php + +/** + * @file + * Contains the FeedsCSVParser class. + */ + +/** + * Parses a given file as a CSV file. + */ +class FeedsCSVParser extends FeedsParser { + + /** + * Implements FeedsParser::parse(). + */ + public function parse(FeedsSource $source, FeedsFetcherResult $fetcher_result) { + $source_config = $source->getConfigFor($this); + $state = $source->state(FEEDS_PARSE); + + // Load and configure parser. + feeds_include_library('ParserCSV.inc', 'ParserCSV'); + $parser = new ParserCSV(); + $delimiter = $source_config['delimiter'] == 'TAB' ? "\t" : $source_config['delimiter']; + $parser->setDelimiter($delimiter); + + $iterator = new ParserCSVIterator($fetcher_result->getFilePath()); + if (empty($source_config['no_headers'])) { + // Get first line and use it for column names, convert them to lower case. + $header = $this->parseHeader($parser, $iterator); + if (!$header) { + return; + } + $parser->setColumnNames($header); + } + + // Determine section to parse, parse. + $start = $state->pointer ? $state->pointer : $parser->lastLinePos(); + $limit = $source->importer->getLimit(); + $rows = $this->parseItems($parser, $iterator, $start, $limit); + + // Report progress. + $state->total = filesize($fetcher_result->getFilePath()); + $state->pointer = $parser->lastLinePos(); + $progress = $parser->lastLinePos() ? $parser->lastLinePos() : $state->total; + $state->progress($state->total, $progress); + + // Create a result object and return it. + return new FeedsParserResult($rows, $source->feed_nid); + } + + /** + * Get first line and use it for column names, convert them to lower case. + * Be aware that the $parser and iterator objects can be modified in this + * function since they are passed in by reference + * + * @param ParserCSV $parser + * @param ParserCSVIterator $iterator + * @return + * An array of lower-cased column names to use as keys for the parsed items. + */ + protected function parseHeader(ParserCSV $parser, ParserCSVIterator $iterator) { + $parser->setLineLimit(1); + $rows = $parser->parse($iterator); + if (!count($rows)) { + return FALSE; + } + $header = array_shift($rows); + foreach ($header as $i => $title) { + $header[$i] = trim(drupal_strtolower($title)); + } + return $header; + } + + /** + * Parse all of the items from the CSV. + * + * @param ParserCSV $parser + * @param ParserCSVIterator $iterator + * @return + * An array of rows of the CSV keyed by the column names previously set + */ + protected function parseItems(ParserCSV $parser, ParserCSVIterator $iterator, $start = 0, $limit = 0) { + $parser->setLineLimit($limit); + $parser->setStartByte($start); + $rows = $parser->parse($iterator); + return $rows; + } + + /** + * Override parent::getMappingSources(). + */ + public function getMappingSources() { + return FALSE; + } + + /** + * Override parent::getSourceElement() to use only lower keys. + */ + public function getSourceElement(FeedsSource $source, FeedsParserResult $result, $element_key) { + return parent::getSourceElement($source, $result, drupal_strtolower($element_key)); + } + + /** + * Define defaults. + */ + public function sourceDefaults() { + return array( + 'delimiter' => $this->config['delimiter'], + 'no_headers' => $this->config['no_headers'], + ); + } + + /** + * Source form. + * + * Show mapping configuration as a guidance for import form users. + */ + public function sourceForm($source_config) { + $form = array(); + $form['#weight'] = -10; + + $mappings = feeds_importer($this->id)->processor->config['mappings']; + $sources = $uniques = array(); + foreach ($mappings as $mapping) { + $sources[] = check_plain($mapping['source']); + if ($mapping['unique']) { + $uniques[] = check_plain($mapping['source']); + } + } + + $output = t('Import !csv_files with one or more of these columns: !columns.', array('!csv_files' => l(t('CSV files'), 'http://en.wikipedia.org/wiki/Comma-separated_values'), '!columns' => implode(', ', $sources))); + $items = array(); + $items[] = format_plural(count($uniques), t('Column <strong>!column</strong> is mandatory and considered unique: only one item per !column value will be created.', array('!column' => implode(', ', $uniques))), t('Columns <strong>!columns</strong> are mandatory and values in these columns are considered unique: only one entry per value in one of these column will be created.', array('!columns' => implode(', ', $uniques)))); + $items[] = l(t('Download a template'), 'import/' . $this->id . '/template'); + $form['help']['#markup'] = '<div class="help"><p>' . $output . '</p>' . theme('item_list', array('items' => $items)) . '</div>'; + $form['delimiter'] = array( + '#type' => 'select', + '#title' => t('Delimiter'), + '#description' => t('The character that delimits fields in the CSV file.'), + '#options' => array( + ',' => ',', + ';' => ';', + 'TAB' => 'TAB', + ), + '#default_value' => isset($source_config['delimiter']) ? $source_config['delimiter'] : ',', + ); + $form['no_headers'] = array( + '#type' => 'checkbox', + '#title' => t('No Headers'), + '#description' => t('Check if the imported CSV file does not start with a header row. If checked, mapping sources must be named \'0\', \'1\', \'2\' etc.'), + '#default_value' => isset($source_config['no_headers']) ? $source_config['no_headers'] : 0, + ); + return $form; + } + + /** + * Define default configuration. + */ + public function configDefaults() { + return array( + 'delimiter' => ',', + 'no_headers' => 0, + ); + } + + /** + * Build configuration form. + */ + public function configForm(&$form_state) { + $form = array(); + $form['delimiter'] = array( + '#type' => 'select', + '#title' => t('Default delimiter'), + '#description' => t('Default field delimiter.'), + '#options' => array( + ',' => ',', + ';' => ';', + 'TAB' => 'TAB', + ), + '#default_value' => $this->config['delimiter'], + ); + $form['no_headers'] = array( + '#type' => 'checkbox', + '#title' => t('No headers'), + '#description' => t('Check if the imported CSV file does not start with a header row. If checked, mapping sources must be named \'0\', \'1\', \'2\' etc.'), + '#default_value' => $this->config['no_headers'], + ); + return $form; + } + + public function getTemplate() { + $mappings = feeds_importer($this->id)->processor->config['mappings']; + $sources = $uniques = array(); + foreach ($mappings as $mapping) { + if ($mapping['unique']) { + $uniques[] = check_plain($mapping['source']); + } + else { + $sources[] = check_plain($mapping['source']); + } + } + $sep = ','; + $columns = array(); + foreach (array_merge($uniques, $sources) as $col) { + if (strpos($col, $sep) !== FALSE) { + $col = '"' . str_replace('"', '""', $col) . '"'; + } + $columns[] = $col; + } + drupal_add_http_header('Cache-Control', 'max-age=60, must-revalidate'); + drupal_add_http_header('Content-Disposition', 'attachment; filename="' . $this->id . '_template.csv"'); + drupal_add_http_header('Content-type', 'text/csv; charset=utf-8'); + print implode($sep, $columns); + return; + } +} diff --git a/sites/all/modules/feeds/plugins/FeedsFetcher.inc b/sites/all/modules/feeds/plugins/FeedsFetcher.inc new file mode 100644 index 0000000000000000000000000000000000000000..4bba44d041adce420abeb13af763cd59d2fbe8aa --- /dev/null +++ b/sites/all/modules/feeds/plugins/FeedsFetcher.inc @@ -0,0 +1,218 @@ +<?php + +/** + * @file + * Contains the FeedsFetcher and related classes. + */ + +/** + * Base class for all fetcher results. + */ +class FeedsFetcherResult extends FeedsResult { + protected $raw; + protected $file_path; + + /** + * Constructor. + */ + public function __construct($raw) { + $this->raw = $raw; + } + + /** + * @return + * The raw content from the source as a string. + * + * @throws Exception + * Extending classes MAY throw an exception if a problem occurred. + */ + public function getRaw() { + return $this->sanitizeRaw($this->raw); + } + + /** + * Get a path to a temporary file containing the resource provided by the + * fetcher. + * + * File will be deleted after DRUPAL_MAXIMUM_TEMP_FILE_AGE. + * + * @return + * A path to a file containing the raw content as a source. + * + * @throws Exception + * If an unexpected problem occurred. + */ + public function getFilePath() { + if (!isset($this->file_path)) { + $destination = 'public://feeds'; + if (!file_prepare_directory($destination, FILE_CREATE_DIRECTORY | FILE_MODIFY_PERMISSIONS)) { + throw new Exception(t('Feeds directory either cannot be created or is not writable.')); + } + $this->file_path = FALSE; + if ($file = file_save_data($this->getRaw(), $destination . '/' . get_class($this) . REQUEST_TIME)) { + $file->status = 0; + file_save($file); + $this->file_path = $file->uri; + } + else { + throw new Exception(t('Cannot write content to %dest', array('%dest' => $destination))); + } + } + return $this->sanitizeFile($this->file_path); + } + + /** + * Sanitize the raw content string. Currently supported sanitizations: + * + * - Remove BOM header from UTF-8 files. + * + * @param string $raw + * The raw content string to be sanitized. + * @return + * The sanitized content as a string. + */ + public function sanitizeRaw($raw) { + if (substr($raw, 0, 3) == pack('CCC', 0xef, 0xbb, 0xbf)) { + $raw = substr($raw, 3); + } + return $raw; + } + + /** + * Sanitize the file in place. Currently supported sanitizations: + * + * - Remove BOM header from UTF-8 files. + * + * @param string $filepath + * The file path of the file to be sanitized. + * @return + * The file path of the sanitized file. + */ + public function sanitizeFile($filepath) { + $handle = fopen($filepath, 'r'); + $line = fgets($handle); + fclose($handle); + // If BOM header is present, read entire contents of file and overwrite + // the file with corrected contents. + if (substr($line, 0, 3) == pack('CCC', 0xef, 0xbb, 0xbf)) { + $contents = file_get_contents($filepath); + $contents = substr($contents, 3); + $status = file_put_contents($filepath, $contents); + if ($status === FALSE) { + throw new Exception(t('File @filepath is not writeable.', array('@filepath' => $filepath))); + } + } + return $filepath; + } +} + +/** + * Abstract class, defines shared functionality between fetchers. + * + * Implements FeedsSourceInfoInterface to expose source forms to Feeds. + */ +abstract class FeedsFetcher extends FeedsPlugin { + + /** + * Fetch content from a source and return it. + * + * Every class that extends FeedsFetcher must implement this method. + * + * @param $source + * Source value as entered by user through sourceForm(). + * + * @return + * A FeedsFetcherResult object. + */ + public abstract function fetch(FeedsSource $source); + + /** + * Clear all caches for results for given source. + * + * @param FeedsSource $source + * Source information for this expiry. Implementers can choose to only clear + * caches pertaining to this source. + */ + public function clear(FeedsSource $source) {} + + /** + * Request handler invoked if callback URL is requested. Locked down by + * default. For a example usage see FeedsHTTPFetcher. + * + * Note: this method may exit the script. + * + * @return + * A string to be returned to the client. + */ + public function request($feed_nid = 0) { + drupal_access_denied(); + } + + /** + * Construct a path for a concrete fetcher/source combination. The result of + * this method matches up with the general path definition in + * FeedsFetcher::menuItem(). For example usage look at FeedsHTTPFetcher. + * + * @return + * Path for this fetcher/source combination. + */ + public function path($feed_nid = 0) { + $id = urlencode($this->id); + if ($feed_nid && is_numeric($feed_nid)) { + return "feeds/importer/$id/$feed_nid"; + } + return "feeds/importer/$id"; + } + + /** + * Menu item definition for fetchers of this class. Note how the path + * component in the item definition matches the return value of + * FeedsFetcher::path(); + * + * Requests to this menu item will be routed to FeedsFetcher::request(). + * + * @return + * An array where the key is the Drupal menu item path and the value is + * a valid Drupal menu item definition. + */ + public function menuItem() { + return array( + 'feeds/importer/%feeds_importer' => array( + 'page callback' => 'feeds_fetcher_callback', + 'page arguments' => array(2, 3), + 'access callback' => TRUE, + 'file' => 'feeds.pages.inc', + 'type' => MENU_CALLBACK, + ), + ); + } + + /** + * Subscribe to a source. Only implement if fetcher requires subscription. + * + * @param FeedsSource $source + * Source information for this subscription. + */ + public function subscribe(FeedsSource $source) {} + + /** + * Unsubscribe from a source. Only implement if fetcher requires subscription. + * + * @param FeedsSource $source + * Source information for unsubscribing. + */ + public function unsubscribe(FeedsSource $source) {} + + /** + * Override import period settings. This can be used to force a certain import + * interval. + * + * @param $source + * A FeedsSource object. + * + * @return + * A time span in seconds if periodic import should be overridden for given + * $source, NULL otherwise. + */ + public function importPeriod(FeedsSource $source) {} +} diff --git a/sites/all/modules/feeds/plugins/FeedsFileFetcher.inc b/sites/all/modules/feeds/plugins/FeedsFileFetcher.inc new file mode 100644 index 0000000000000000000000000000000000000000..03ef72a003bd7753dfba881be47796b574149187 --- /dev/null +++ b/sites/all/modules/feeds/plugins/FeedsFileFetcher.inc @@ -0,0 +1,229 @@ +<?php + +/** + * @file + * Home of the FeedsFileFetcher and related classes. + */ + +/** + * Definition of the import batch object created on the fetching stage by + * FeedsFileFetcher. + */ +class FeedsFileFetcherResult extends FeedsFetcherResult { + /** + * Constructor. + */ + public function __construct($file_path) { + parent::__construct(''); + $this->file_path = $file_path; + } + + /** + * Overrides parent::getRaw(); + */ + public function getRaw() { + return $this->sanitizeRaw(file_get_contents($this->file_path)); + } + + /** + * Overrides parent::getFilePath(). + */ + public function getFilePath() { + if (!file_exists($this->file_path)) { + throw new Exception(t('File @filepath is not accessible.', array('@filepath' => $this->file_path))); + } + return $this->sanitizeFile($this->file_path); + } +} + +/** + * Fetches data via HTTP. + */ +class FeedsFileFetcher extends FeedsFetcher { + + /** + * Implements FeedsFetcher::fetch(). + */ + public function fetch(FeedsSource $source) { + $source_config = $source->getConfigFor($this); + + // Just return a file fetcher result if this is a file. + if (is_file($source_config['source'])) { + return new FeedsFileFetcherResult($source_config['source']); + } + + // Batch if this is a directory. + $state = $source->state(FEEDS_FETCH); + $files = array(); + if (!isset($state->files)) { + $state->files = $this->listFiles($source_config['source']); + $state->total = count($state->files); + } + if (count($state->files)) { + $file = array_shift($state->files); + $state->progress($state->total, $state->total - count($state->files)); + return new FeedsFileFetcherResult($file); + } + + throw new Exception(t('Resource is not a file or it is an empty directory: %source', array('%source' => $source_config['source']))); + } + + /** + * Return an array of files in a directory. + * + * @param $dir + * A stream wreapper URI that is a directory. + * + * @return + * An array of stream wrapper URIs pointing to files. The array is empty + * if no files could be found. Never contains directories. + */ + protected function listFiles($dir) { + $dir = file_stream_wrapper_uri_normalize($dir); + $files = array(); + if ($items = @scandir($dir)) { + foreach ($items as $item) { + if (is_file("$dir/$item") && strpos($item, '.') !== 0) { + $files[] = "$dir/$item"; + } + } + } + return $files; + } + + /** + * Source form. + */ + public function sourceForm($source_config) { + $form = array(); + $form['fid'] = array( + '#type' => 'value', + '#value' => empty($source_config['fid']) ? 0 : $source_config['fid'], + ); + if (empty($this->config['direct'])) { + $form['source'] = array( + '#type' => 'value', + '#value' => empty($source_config['source']) ? '' : $source_config['source'], + ); + $form['upload'] = array( + '#type' => 'file', + '#title' => empty($this->config['direct']) ? t('File') : NULL, + '#description' => empty($source_config['source']) ? t('Select a file from your local system.') : t('Select a different file from your local system.'), + '#theme' => 'feeds_upload', + '#file_info' => empty($source_config['fid']) ? NULL : file_load($source_config['fid']), + '#size' => 10, + ); + } + else { + $form['source'] = array( + '#type' => 'textfield', + '#title' => t('File'), + '#description' => t('Specify a path to a file or a directory. Path must start with @scheme://', array('@scheme' => file_default_scheme())), + '#default_value' => empty($source_config['source']) ? '' : $source_config['source'], + ); + } + return $form; + } + + /** + * Override parent::sourceFormValidate(). + */ + public function sourceFormValidate(&$values) { + $values['source'] = trim($values['source']); + + $feed_dir = 'public://feeds'; + file_prepare_directory($feed_dir, FILE_CREATE_DIRECTORY | FILE_MODIFY_PERMISSIONS); + + // If there is a file uploaded, save it, otherwise validate input on + // file. + // @todo: Track usage of file, remove file when removing source. + if ($file = file_save_upload('feeds', array('file_validate_extensions' => array(0 => $this->config['allowed_extensions'])), $feed_dir)) { + $values['source'] = $file->uri; + $values['file'] = $file; + } + elseif (empty($values['source'])) { + form_set_error('feeds][source', t('Upload a file first.')); + } + // If a file has not been uploaded and $values['source'] is not empty, make + // sure that this file is within Drupal's files directory as otherwise + // potentially any file that the web server has access to could be exposed. + elseif (strpos($values['source'], file_default_scheme()) !== 0) { + form_set_error('feeds][source', t('File needs to reside within the site\'s file directory, its path needs to start with @scheme://.', array('@scheme' => file_default_scheme()))); + } + } + + /** + * Override parent::sourceSave(). + */ + public function sourceSave(FeedsSource $source) { + $source_config = $source->getConfigFor($this); + + // If a new file is present, delete the old one and replace it with the new + // one. + if (isset($source_config['file'])) { + $file = $source_config['file']; + if (isset($source_config['fid'])) { + $this->deleteFile($source_config['fid'], $source->feed_nid); + } + $file->status = FILE_STATUS_PERMANENT; + file_save($file); + file_usage_add($file, 'feeds', get_class($this), $source->feed_nid); + + $source_config['fid'] = $file->fid; + unset($source_config['file']); + $source->setConfigFor($this, $source_config); + } + } + + /** + * Override parent::sourceDelete(). + */ + public function sourceDelete(FeedsSource $source) { + $source_config = $source->getConfigFor($this); + if (isset($source_config['fid'])) { + $this->deleteFile($source_config['fid'], $source->feed_nid); + } + } + + /** + * Override parent::configDefaults(). + */ + public function configDefaults() { + return array( + 'allowed_extensions' => 'txt csv tsv xml opml', + 'direct' => FALSE, + ); + } + + /** + * Override parent::configForm(). + */ + public function configForm(&$form_state) { + $form = array(); + $form['allowed_extensions'] = array( + '#type' => 'textfield', + '#title' => t('Allowed file extensions'), + '#description' => t('Allowed file extensions for upload.'), + '#default_value' => $this->config['allowed_extensions'], + ); + $form['direct'] = array( + '#type' => 'checkbox', + '#title' => t('Supply path to file or directory directly'), + '#description' => t('For experts. Lets users specify a path to a file <em>or a directory of files</em> directly, + instead of a file upload through the browser. This is useful when the files that need to be imported + are already on the server.'), + '#default_value' => $this->config['direct'], + ); + return $form; + } + + /** + * Helper. Deletes a file. + */ + protected function deleteFile($fid, $feed_nid) { + if ($file = file_load($fid)) { + file_usage_delete($file, 'feeds', get_class($this), $feed_nid); + file_delete($file); + } + } +} diff --git a/sites/all/modules/feeds/plugins/FeedsHTTPFetcher.inc b/sites/all/modules/feeds/plugins/FeedsHTTPFetcher.inc new file mode 100644 index 0000000000000000000000000000000000000000..119c0bf25101567e15e3acb2a88e59b6e3d39f37 --- /dev/null +++ b/sites/all/modules/feeds/plugins/FeedsHTTPFetcher.inc @@ -0,0 +1,332 @@ +<?php + +/** + * @file + * Home of the FeedsHTTPFetcher and related classes. + */ + +feeds_include_library('PuSHSubscriber.inc', 'PuSHSubscriber'); + +/** + * Result of FeedsHTTPFetcher::fetch(). + */ +class FeedsHTTPFetcherResult extends FeedsFetcherResult { + protected $url; + protected $file_path; + + /** + * Constructor. + */ + public function __construct($url = NULL) { + $this->url = $url; + parent::__construct(''); + } + + /** + * Overrides FeedsFetcherResult::getRaw(); + */ + public function getRaw() { + feeds_include_library('http_request.inc', 'http_request'); + $result = http_request_get($this->url); + if (!in_array($result->code, array(200, 201, 202, 203, 204, 205, 206))) { + throw new Exception(t('Download of @url failed with code !code.', array('@url' => $this->url, '!code' => $result->code))); + } + return $this->sanitizeRaw($result->data); + } +} + +/** + * Fetches data via HTTP. + */ +class FeedsHTTPFetcher extends FeedsFetcher { + + /** + * Implements FeedsFetcher::fetch(). + */ + public function fetch(FeedsSource $source) { + $source_config = $source->getConfigFor($this); + if ($this->config['use_pubsubhubbub'] && ($raw = $this->subscriber($source->feed_nid)->receive())) { + return new FeedsFetcherResult($raw); + } + return new FeedsHTTPFetcherResult($source_config['source']); + } + + /** + * Clear caches. + */ + public function clear(FeedsSource $source) { + $source_config = $source->getConfigFor($this); + $url = $source_config['source']; + feeds_include_library('http_request.inc', 'http_request'); + http_request_clear_cache($url); + } + + /** + * Implements FeedsFetcher::request(). + */ + public function request($feed_nid = 0) { + feeds_dbg($_GET); + @feeds_dbg(file_get_contents('php://input')); + // A subscription verification has been sent, verify. + if (isset($_GET['hub_challenge'])) { + $this->subscriber($feed_nid)->verifyRequest(); + } + // No subscription notification has ben sent, we are being notified. + else { + try { + feeds_source($this->id, $feed_nid)->existing()->import(); + } + catch (Exception $e) { + // In case of an error, respond with a 503 Service (temporary) unavailable. + header('HTTP/1.1 503 "Not Found"', NULL, 503); + drupal_exit(); + } + } + // Will generate the default 200 response. + header('HTTP/1.1 200 "OK"', NULL, 200); + drupal_exit(); + } + + /** + * Override parent::configDefaults(). + */ + public function configDefaults() { + return array( + 'auto_detect_feeds' => FALSE, + 'use_pubsubhubbub' => FALSE, + 'designated_hub' => '', + ); + } + + /** + * Override parent::configForm(). + */ + public function configForm(&$form_state) { + $form = array(); + $form['auto_detect_feeds'] = array( + '#type' => 'checkbox', + '#title' => t('Auto detect feeds'), + '#description' => t('If the supplied URL does not point to a feed but an HTML document, attempt to extract a feed URL from the document.'), + '#default_value' => $this->config['auto_detect_feeds'], + ); + $form['use_pubsubhubbub'] = array( + '#type' => 'checkbox', + '#title' => t('Use PubSubHubbub'), + '#description' => t('Attempt to use a <a href="http://en.wikipedia.org/wiki/PubSubHubbub">PubSubHubbub</a> subscription if available.'), + '#default_value' => $this->config['use_pubsubhubbub'], + ); + $form['designated_hub'] = array( + '#type' => 'textfield', + '#title' => t('Designated hub'), + '#description' => t('Enter the URL of a designated PubSubHubbub hub (e. g. superfeedr.com). If given, this hub will be used instead of the hub specified in the actual feed.'), + '#default_value' => $this->config['designated_hub'], + '#dependency' => array( + 'edit-use-pubsubhubbub' => array(1), + ), + ); + return $form; + } + + /** + * Expose source form. + */ + public function sourceForm($source_config) { + $form = array(); + $form['source'] = array( + '#type' => 'textfield', + '#title' => t('URL'), + '#description' => t('Enter a feed URL.'), + '#default_value' => isset($source_config['source']) ? $source_config['source'] : '', + '#maxlength' => NULL, + '#required' => TRUE, + ); + return $form; + } + + /** + * Override parent::sourceFormValidate(). + */ + public function sourceFormValidate(&$values) { + $values['source'] = trim($values['source']); + + if (!feeds_valid_url($values['source'], TRUE)) { + $form_key = 'feeds][' . get_class($this) . '][source'; + form_set_error($form_key, t('The URL %source is invalid.', array('%source' => $values['source']))); + } + elseif ($this->config['auto_detect_feeds']) { + feeds_include_library('http_request.inc', 'http_request'); + if ($url = http_request_get_common_syndication($values['source'])) { + $values['source'] = $url; + } + } + } + + /** + * Override sourceSave() - subscribe to hub. + */ + public function sourceSave(FeedsSource $source) { + if ($this->config['use_pubsubhubbub']) { + // If this is a feeds node we want to delay the subscription to + // feeds_exit() to avoid transaction race conditions. + if ($source->feed_nid) { + $job = array('fetcher' => $this, 'source' => $source); + feeds_set_subscription_job($job); + } + else { + $this->subscribe($source); + } + } + } + + /** + * Override sourceDelete() - unsubscribe from hub. + */ + public function sourceDelete(FeedsSource $source) { + if ($this->config['use_pubsubhubbub']) { + // If we're in a feed node, queue the unsubscribe, + // else process immediately. + if ($source->feed_nid) { + $job = array( + 'type' => $source->id, + 'id' => $source->feed_nid, + 'period' => 0, + 'periodic' => FALSE, + ); + JobScheduler::get('feeds_push_unsubscribe')->set($job); + } + else { + $this->unsubscribe($source); + } + } + } + + /** + * Implement FeedsFetcher::subscribe() - subscribe to hub. + */ + public function subscribe(FeedsSource $source) { + $source_config = $source->getConfigFor($this); + $this->subscriber($source->feed_nid)->subscribe($source_config['source'], url($this->path($source->feed_nid), array('absolute' => TRUE)), valid_url($this->config['designated_hub']) ? $this->config['designated_hub'] : ''); + } + + /** + * Implement FeedsFetcher::unsubscribe() - unsubscribe from hub. + */ + public function unsubscribe(FeedsSource $source) { + $source_config = $source->getConfigFor($this); + $this->subscriber($source->feed_nid)->unsubscribe($source_config['source'], url($this->path($source->feed_nid), array('absolute' => TRUE))); + } + + /** + * Implement FeedsFetcher::importPeriod(). + */ + public function importPeriod(FeedsSource $source) { + if ($this->subscriber($source->feed_nid)->subscribed()) { + return 259200; // Delay for three days if there is a successful subscription. + } + } + + /** + * Convenience method for instantiating a subscriber object. + */ + protected function subscriber($subscriber_id) { + return PushSubscriber::instance($this->id, $subscriber_id, 'PuSHSubscription', PuSHEnvironment::instance()); + } +} + +/** + * Implement a PuSHSubscriptionInterface. + */ +class PuSHSubscription implements PuSHSubscriptionInterface { + public $domain; + public $subscriber_id; + public $hub; + public $topic; + public $status; + public $secret; + public $post_fields; + public $timestamp; + + /** + * Load a subscription. + */ + public static function load($domain, $subscriber_id) { + if ($v = db_query("SELECT * FROM {feeds_push_subscriptions} WHERE domain = :domain AND subscriber_id = :sid", array(':domain' => $domain, ':sid' => $subscriber_id))->fetchAssoc()) { + $v['post_fields'] = unserialize($v['post_fields']); + return new PuSHSubscription($v['domain'], $v['subscriber_id'], $v['hub'], $v['topic'], $v['secret'], $v['status'], $v['post_fields'], $v['timestamp']); + } + } + + /** + * Create a subscription. + */ + public function __construct($domain, $subscriber_id, $hub, $topic, $secret, $status = '', $post_fields = '') { + $this->domain = $domain; + $this->subscriber_id = $subscriber_id; + $this->hub = $hub; + $this->topic = $topic; + $this->status = $status; + $this->secret = $secret; + $this->post_fields = $post_fields; + } + + /** + * Save a subscription. + */ + public function save() { + $this->timestamp = time(); + $this->delete($this->domain, $this->subscriber_id); + drupal_write_record('feeds_push_subscriptions', $this); + } + + /** + * Delete a subscription. + */ + public function delete() { + db_delete('feeds_push_subscriptions') + ->condition('domain', $this->domain) + ->condition('subscriber_id', $this->subscriber_id) + ->execute(); + } +} + +/** + * Provide environmental functions to the PuSHSubscriber library. + */ +class PuSHEnvironment implements PuSHSubscriberEnvironmentInterface { + /** + * Singleton. + */ + public static function instance() { + static $env; + if (empty($env)) { + $env = new PuSHEnvironment(); + } + return $env; + } + + /** + * Implements PuSHSubscriberEnvironmentInterface::msg(). + */ + public function msg($msg, $level = 'status') { + drupal_set_message(check_plain($msg), $level); + } + + /** + * Implements PuSHSubscriberEnvironmentInterface::log(). + */ + public function log($msg, $level = 'status') { + switch ($level) { + case 'error': + $severity = WATCHDOG_ERROR; + break; + case 'warning': + $severity = WATCHDOG_WARNING; + break; + default: + $severity = WATCHDOG_NOTICE; + break; + } + feeds_dbg($msg); + watchdog('FeedsHTTPFetcher', $msg, array(), $severity); + } +} diff --git a/sites/all/modules/feeds/plugins/FeedsNodeProcessor.inc b/sites/all/modules/feeds/plugins/FeedsNodeProcessor.inc new file mode 100644 index 0000000000000000000000000000000000000000..d2080473e25670fd72c734048606cf6b15423dd4 --- /dev/null +++ b/sites/all/modules/feeds/plugins/FeedsNodeProcessor.inc @@ -0,0 +1,391 @@ +<?php + +/** + * @file + * Class definition of FeedsNodeProcessor. + */ + +/** + * Creates nodes from feed items. + */ +class FeedsNodeProcessor extends FeedsProcessor { + /** + * Define entity type. + */ + public function entityType() { + return 'node'; + } + + /** + * Implements parent::entityInfo(). + */ + protected function entityInfo() { + $info = parent::entityInfo(); + $info['label plural'] = t('Nodes'); + return $info; + } + + /** + * Creates a new node in memory and returns it. + */ + protected function newEntity(FeedsSource $source) { + $node = new stdClass(); + $node->type = $this->config['content_type']; + $node->changed = REQUEST_TIME; + $node->created = REQUEST_TIME; + $node->language = LANGUAGE_NONE; + node_object_prepare($node); + // Populate properties that are set by node_object_prepare(). + $node->log = 'Created by FeedsNodeProcessor'; + $node->uid = $this->config['author']; + return $node; + } + + /** + * Loads an existing node. + * + * If the update existing method is not FEEDS_UPDATE_EXISTING, only the node + * table will be loaded, foregoing the node_load API for better performance. + * + * @todo Reevaluate the use of node_object_prepare(). + */ + protected function entityLoad(FeedsSource $source, $nid) { + if ($this->config['update_existing'] == FEEDS_UPDATE_EXISTING) { + $node = node_load($nid, NULL, TRUE); + } + else { + // We're replacing the existing node. Only save the absolutely necessary. + $node = db_query("SELECT created, nid, vid, type, status FROM {node} WHERE nid = :nid", array(':nid' => $nid))->fetchObject(); + $node->uid = $this->config['author']; + } + node_object_prepare($node); + + // Workaround for issue #1247506. See #1245094 for backstory. + if (!empty($node->menu)) { + // If the node has a menu item(with a valid mlid) it must be flagged + // 'enabled'. + $node->menu['enabled'] = (int) (bool) $node->menu['mlid']; + } + + // Populate properties that are set by node_object_prepare(). + if ($this->config['update_existing'] == FEEDS_UPDATE_EXISTING) { + $node->log = 'Updated by FeedsNodeProcessor'; + } + else { + $node->log = 'Replaced by FeedsNodeProcessor'; + } + return $node; + } + + /** + * Check that the user has permission to save a node. + */ + protected function entitySaveAccess($entity) { + + // The check will be skipped for anonymous nodes. + if ($this->config['authorize'] && !empty($entity->uid)) { + + $author = user_load($entity->uid); + + // If the uid was mapped directly, rather than by email or username, it + // could be invalid. + if (!$author) { + $message = 'User %uid is not a valid user.'; + throw new FeedsAccessException(t($message, array('%uid' => $entity->uid))); + } + + if (empty($entity->nid) || !empty($entity->is_new)) { + $op = 'create'; + $access = node_access($op, $entity->type, $author); + } + else { + $op = 'update'; + $access = node_access($op, $entity, $author); + } + + if (!$access) { + $message = 'User %name is not authorized to %op content type %content_type.'; + throw new FeedsAccessException(t($message, array('%name' => $author->name, '%op' => $op, '%content_type' => $entity->type))); + } + } + } + + /** + * Save a node. + */ + public function entitySave($entity) { + // If nid is set and a node with that id doesn't exist, flag as new. + if (!empty($entity->nid) && !node_load($entity->nid)) { + $entity->is_new = TRUE; + } + node_save($entity); + } + + /** + * Delete a series of nodes. + */ + protected function entityDeleteMultiple($nids) { + node_delete_multiple($nids); + } + + /** + * Implement expire(). + * + * @todo: move to processor stage? + */ + public function expire($time = NULL) { + if ($time === NULL) { + $time = $this->expiryTime(); + } + if ($time == FEEDS_EXPIRE_NEVER) { + return; + } + $count = $this->getLimit(); + $nodes = db_query_range("SELECT n.nid FROM {node} n JOIN {feeds_item} fi ON fi.entity_type = 'node' AND n.nid = fi.entity_id WHERE fi.id = :id AND n.created < :created", 0, $count, array(':id' => $this->id, ':created' => REQUEST_TIME - $time)); + $nids = array(); + foreach ($nodes as $node) { + $nids[$node->nid] = $node->nid; + } + $this->entityDeleteMultiple($nids); + if (db_query_range("SELECT 1 FROM {node} n JOIN {feeds_item} fi ON fi.entity_type = 'node' AND n.nid = fi.entity_id WHERE fi.id = :id AND n.created < :created", 0, 1, array(':id' => $this->id, ':created' => REQUEST_TIME - $time))->fetchField()) { + return FEEDS_BATCH_ACTIVE; + } + return FEEDS_BATCH_COMPLETE; + } + + /** + * Return expiry time. + */ + public function expiryTime() { + return $this->config['expire']; + } + + /** + * Override parent::configDefaults(). + */ + public function configDefaults() { + $types = node_type_get_names(); + $type = isset($types['article']) ? 'article' : key($types); + return array( + 'content_type' => $type, + 'expire' => FEEDS_EXPIRE_NEVER, + 'author' => 0, + 'authorize' => TRUE, + ) + parent::configDefaults(); + } + + /** + * Override parent::configForm(). + */ + public function configForm(&$form_state) { + $types = node_type_get_names(); + array_walk($types, 'check_plain'); + $form = parent::configForm($form_state); + $form['content_type'] = array( + '#type' => 'select', + '#title' => t('Content type'), + '#description' => t('Select the content type for the nodes to be created. <strong>Note:</strong> Users with "import !feed_id feeds" permissions will be able to <strong>import</strong> nodes of the content type selected here regardless of the node level permissions. Further, users with "clear !feed_id permissions" will be able to <strong>delete</strong> imported nodes regardless of their node level permissions.', array('!feed_id' => $this->id)), + '#options' => $types, + '#default_value' => $this->config['content_type'], + ); + $author = user_load($this->config['author']); + $form['author'] = array( + '#type' => 'textfield', + '#title' => t('Author'), + '#description' => t('Select the author of the nodes to be created - leave empty to assign "anonymous".'), + '#autocomplete_path' => 'user/autocomplete', + '#default_value' => empty($author->name) ? 'anonymous' : check_plain($author->name), + ); + $form['authorize'] = array( + '#type' => 'checkbox', + '#title' => t('Authorize'), + '#description' => t('Check that the author has permission to create the node.'), + '#default_value' => $this->config['authorize'], + ); + $period = drupal_map_assoc(array(FEEDS_EXPIRE_NEVER, 3600, 10800, 21600, 43200, 86400, 259200, 604800, 2592000, 2592000 * 3, 2592000 * 6, 31536000), 'feeds_format_expire'); + $form['expire'] = array( + '#type' => 'select', + '#title' => t('Expire nodes'), + '#options' => $period, + '#description' => t('Select after how much time nodes should be deleted. The node\'s published date will be used for determining the node\'s age, see Mapping settings.'), + '#default_value' => $this->config['expire'], + ); + $form['update_existing']['#options'] = array( + FEEDS_SKIP_EXISTING => 'Do not update existing nodes', + FEEDS_REPLACE_EXISTING => 'Replace existing nodes', + FEEDS_UPDATE_EXISTING => 'Update existing nodes (slower than replacing them)', + ); + return $form; + } + + /** + * Override parent::configFormValidate(). + */ + public function configFormValidate(&$values) { + if ($author = user_load_by_name($values['author'])) { + $values['author'] = $author->uid; + } + else { + $values['author'] = 0; + } + } + + /** + * Reschedule if expiry time changes. + */ + public function configFormSubmit(&$values) { + if ($this->config['expire'] != $values['expire']) { + feeds_reschedule($this->id); + } + parent::configFormSubmit($values); + } + + /** + * Override setTargetElement to operate on a target item that is a node. + */ + public function setTargetElement(FeedsSource $source, $target_node, $target_element, $value) { + switch ($target_element) { + case 'created': + $target_node->created = feeds_to_unixtime($value, REQUEST_TIME); + break; + case 'feeds_source': + // Get the class of the feed node importer's fetcher and set the source + // property. See feeds_node_update() how $node->feeds gets stored. + if ($id = feeds_get_importer_id($this->config['content_type'])) { + $class = get_class(feeds_importer($id)->fetcher); + $target_node->feeds[$class]['source'] = $value; + // This effectively suppresses 'import on submission' feature. + // See feeds_node_insert(). + $target_node->feeds['suppress_import'] = TRUE; + } + break; + case 'user_name': + if ($user = user_load_by_name($value)) { + $target_node->uid = $user->uid; + } + break; + case 'user_mail': + if ($user = user_load_by_mail($value)) { + $target_node->uid = $user->uid; + } + break; + default: + parent::setTargetElement($source, $target_node, $target_element, $value); + break; + } + } + + /** + * Return available mapping targets. + */ + public function getMappingTargets() { + $type = node_type_get_type($this->config['content_type']); + $targets = parent::getMappingTargets(); + if ($type->has_title) { + $targets['title'] = array( + 'name' => t('Title'), + 'description' => t('The title of the node.'), + 'optional_unique' => TRUE, + ); + } + $targets['nid'] = array( + 'name' => t('Node ID'), + 'description' => t('The nid of the node. NOTE: use this feature with care, node ids are usually assigned by Drupal.'), + 'optional_unique' => TRUE, + ); + $targets['uid'] = array( + 'name' => t('User ID'), + 'description' => t('The Drupal user ID of the node author.'), + ); + $targets['user_name'] = array( + 'name' => t('Username'), + 'description' => t('The Drupal username of the node author.'), + ); + $targets['user_mail'] = array( + 'name' => t('User email'), + 'description' => t('The email address of the node author.'), + ); + $targets['status'] = array( + 'name' => t('Published status'), + 'description' => t('Whether a node is published or not. 1 stands for published, 0 for not published.'), + ); + $targets['created'] = array( + 'name' => t('Published date'), + 'description' => t('The UNIX time when a node has been published.'), + ); + $targets['promote'] = array( + 'name' => t('Promoted to front page'), + 'description' => t('Boolean value, whether or not node is promoted to front page. (1 = promoted, 0 = not promoted)'), + ); + $targets['sticky'] = array( + 'name' => t('Sticky'), + 'description' => t('Boolean value, whether or not node is sticky at top of lists. (1 = sticky, 0 = not sticky)'), + ); + + // Include language field if Locale module is enabled. + if (module_exists('locale')) { + $targets['language'] = array( + 'name' => t('Language'), + 'description' => t('The two-character language code of the node.'), + ); + } + + // Include comment field if Comment module is enabled. + if (module_exists('comment')) { + $targets['comment'] = array( + 'name' => t('Comments'), + 'description' => t('Whether comments are allowed on this node: 0 = no, 1 = read only, 2 = read/write.'), + ); + } + + // If the target content type is a Feed node, expose its source field. + if ($id = feeds_get_importer_id($this->config['content_type'])) { + $name = feeds_importer($id)->config['name']; + $targets['feeds_source'] = array( + 'name' => t('Feed source'), + 'description' => t('The content type created by this processor is a Feed Node, it represents a source itself. Depending on the fetcher selected on the importer "@importer", this field is expected to be for example a URL or a path to a file.', array('@importer' => $name)), + 'optional_unique' => TRUE, + ); + } + + // Let other modules expose mapping targets. + self::loadMappers(); + $entity_type = $this->entityType(); + $bundle = $this->config['content_type']; + drupal_alter('feeds_processor_targets', $targets, $entity_type, $bundle); + + return $targets; + } + + /** + * Get nid of an existing feed item node if available. + */ + protected function existingEntityId(FeedsSource $source, FeedsParserResult $result) { + if ($nid = parent::existingEntityId($source, $result)) { + return $nid; + } + + // Iterate through all unique targets and test whether they do already + // exist in the database. + foreach ($this->uniqueTargets($source, $result) as $target => $value) { + switch ($target) { + case 'nid': + $nid = db_query("SELECT nid FROM {node} WHERE nid = :nid", array(':nid' => $value))->fetchField(); + break; + case 'title': + $nid = db_query("SELECT nid FROM {node} WHERE title = :title AND type = :type", array(':title' => $value, ':type' => $this->config['content_type']))->fetchField(); + break; + case 'feeds_source': + if ($id = feeds_get_importer_id($this->config['content_type'])) { + $nid = db_query("SELECT fs.feed_nid FROM {node} n JOIN {feeds_source} fs ON n.nid = fs.feed_nid WHERE fs.id = :id AND fs.source = :source", array(':id' => $id, ':source' => $value))->fetchField(); + } + break; + } + if ($nid) { + // Return with the first nid found. + return $nid; + } + } + return 0; + } +} diff --git a/sites/all/modules/feeds/plugins/FeedsOPMLParser.inc b/sites/all/modules/feeds/plugins/FeedsOPMLParser.inc new file mode 100644 index 0000000000000000000000000000000000000000..53aeee137473ca4813a5684b369258aa1cd638ed --- /dev/null +++ b/sites/all/modules/feeds/plugins/FeedsOPMLParser.inc @@ -0,0 +1,39 @@ +<?php + +/** + * @file + * OPML Parser plugin. + */ + +/** + * Feeds parser plugin that parses OPML feeds. + */ +class FeedsOPMLParser extends FeedsParser { + + /** + * Implements FeedsParser::parse(). + */ + public function parse(FeedsSource $source, FeedsFetcherResult $fetcher_result) { + feeds_include_library('opml_parser.inc', 'opml_parser'); + $opml = opml_parser_parse($fetcher_result->getRaw()); + $result = new FeedsParserResult($opml['items']); + $result->title = $opml['title']; + return $result; + } + + /** + * Return mapping sources. + */ + public function getMappingSources() { + return array( + 'title' => array( + 'name' => t('Feed title'), + 'description' => t('Title of the feed.'), + ), + 'xmlurl' => array( + 'name' => t('Feed URL'), + 'description' => t('URL of the feed.'), + ), + ) + parent::getMappingSources(); + } +} diff --git a/sites/all/modules/feeds/plugins/FeedsParser.inc b/sites/all/modules/feeds/plugins/FeedsParser.inc new file mode 100644 index 0000000000000000000000000000000000000000..c49231700cd30fecadaa95672e17a5a5b2497e77 --- /dev/null +++ b/sites/all/modules/feeds/plugins/FeedsParser.inc @@ -0,0 +1,757 @@ +<?php + +/** + * @file + * Contains FeedsParser and related classes. + */ + +/** + * A result of a parsing stage. + */ +class FeedsParserResult extends FeedsResult { + public $title; + public $description; + public $link; + public $items; + public $current_item; + + /** + * Constructor. + */ + public function __construct($items = array()) { + $this->title = ''; + $this->description = ''; + $this->link = ''; + $this->items = $items; + } + + /** + * @todo Move to a nextItem() based approach, not consuming the item array. + * Can only be done once we don't cache the entire batch object between page + * loads for batching anymore. + * + * @return + * Next available item or NULL if there is none. Every returned item is + * removed from the internal array. + */ + public function shiftItem() { + $this->current_item = array_shift($this->items); + return $this->current_item; + } + + /** + * @return + * Current result item. + */ + public function currentItem() { + return empty($this->current_item) ? NULL : $this->current_item; + } +} + +/** + * Abstract class, defines interface for parsers. + */ +abstract class FeedsParser extends FeedsPlugin { + + /** + * Parse content fetched by fetcher. + * + * Extending classes must implement this method. + * + * @param FeedsSource $source + * Source information. + * @param $fetcher_result + * FeedsFetcherResult returned by fetcher. + */ + public abstract function parse(FeedsSource $source, FeedsFetcherResult $fetcher_result); + + /** + * Clear all caches for results for given source. + * + * @param FeedsSource $source + * Source information for this expiry. Implementers can choose to only clear + * caches pertaining to this source. + */ + public function clear(FeedsSource $source) {} + + /** + * Declare the possible mapping sources that this parser produces. + * + * @ingroup mappingapi + * + * @return + * An array of mapping sources, or FALSE if the sources can be defined by + * typing a value in a text field. + * + * Example: + * @code + * array( + * 'title' => t('Title'), + * 'created' => t('Published date'), + * 'url' => t('Feed item URL'), + * 'guid' => t('Feed item GUID'), + * ) + * @endcode + */ + public function getMappingSources() { + self::loadMappers(); + $sources = array(); + $content_type = feeds_importer($this->id)->config['content_type']; + drupal_alter('feeds_parser_sources', $sources, $content_type); + if (!feeds_importer($this->id)->config['content_type']) { + return $sources; + } + $sources['parent:uid'] = array( + 'name' => t('Feed node: User ID'), + 'description' => t('The feed node author uid.'), + ); + $sources['parent:nid'] = array( + 'name' => t('Feed node: Node ID'), + 'description' => t('The feed node nid.'), + ); + return $sources; + } + + /** + * Get an element identified by $element_key of the given item. + * The element key corresponds to the values in the array returned by + * FeedsParser::getMappingSources(). + * + * This method is invoked from FeedsProcessor::map() when a concrete item is + * processed. + * + * @ingroup mappingapi + * + * @param $batch + * FeedsImportBatch object containing the sources to be mapped from. + * @param $element_key + * The key identifying the element that should be retrieved from $source + * + * @return + * The source element from $item identified by $element_key. + * + * @see FeedsProcessor::map() + * @see FeedsCSVParser::getSourceElement() + */ + public function getSourceElement(FeedsSource $source, FeedsParserResult $result, $element_key) { + + switch ($element_key) { + + case 'parent:uid': + if ($source->feed_nid && $node = node_load($source->feed_nid)) { + return $node->uid; + } + break; + case 'parent:nid': + return $source->feed_nid; + } + + $item = $result->currentItem(); + return isset($item[$element_key]) ? $item[$element_key] : ''; + } +} + +/** + * Defines an element of a parsed result. Such an element can be a simple type, + * a complex type (derived from FeedsElement) or an array of either. + * + * @see FeedsEnclosure + */ +class FeedsElement { + // The standard value of this element. This value can contain be a simple type, + // a FeedsElement or an array of either. + protected $value; + + /** + * Constructor. + */ + public function __construct($value) { + $this->value = $value; + } + + /** + * @todo Make value public and deprecate use of getValue(). + * + * @return + * Value of this FeedsElement represented as a scalar. + */ + public function getValue() { + return $this->value; + } + + /** + * Magic method __toString() for printing and string conversion of this + * object. + * + * @return + * A string representation of this element. + */ + public function __toString() { + if (is_array($this->value)) { + return 'Array'; + } + if (is_object($this->value)) { + return 'Object'; + } + return (string) $this->getValue(); + } +} + +/** + * Encapsulates a taxonomy style term object. + * + * Objects of this class can be turned into a taxonomy term style arrays by + * casting them. + * + * @code + * $term_object = new FeedsTermElement($term_array); + * $term_array = (array)$term_object; + * @endcode + */ +class FeedsTermElement extends FeedsElement { + public $tid, $vid, $name; + + /** + * @param $term + * An array or a stdClass object that is a Drupal taxonomy term. + */ + public function __construct($term) { + if (is_array($term)) { + parent::__construct($term['name']); + foreach ($this as $key => $value) { + $this->$key = isset($term[$key]) ? $term[$key] : NULL; + } + } + elseif (is_object($term)) { + parent::__construct($term->name); + foreach ($this as $key => $value) { + $this->$key = isset($term->$key) ? $term->$key : NULL; + } + } + } + + /** + * Use $name as $value. + */ + public function getValue() { + return $this->name; + } +} + +/** + * A geo term element. + */ +class FeedsGeoTermElement extends FeedsTermElement { + public $lat, $lon, $bound_top, $bound_right, $bound_bottom, $bound_left, $geometry; + /** + * @param $term + * An array or a stdClass object that is a Drupal taxonomy term. Can include + * geo extensions. + */ + public function __construct($term) { + parent::__construct($term); + } +} + +/** + * Enclosure element, can be part of the result array. + */ +class FeedsEnclosure extends FeedsElement { + protected $mime_type; + + /** + * Constructor, requires MIME type. + * + * @param $value + * A path to a local file or a URL to a remote document. + * @param $mimetype + * The mime type of the resource. + */ + public function __construct($value, $mime_type) { + parent::__construct($value); + $this->mime_type = $mime_type; + } + + /** + * @return + * MIME type of return value of getValue(). + */ + public function getMIMEType() { + return $this->mime_type; + } + + /** + * Use this method instead of FeedsElement::getValue() when fetching the file + * from the URL. + * + * @return + * Value with encoded space characters to safely fetch the file from the URL. + * + * @see FeedsElement::getValue() + */ + public function getUrlEncodedValue() { + return str_replace(' ', '%20', $this->getValue()); + } + + /** + * Use this method instead of FeedsElement::getValue() to get the file name + * transformed for better local saving (underscores instead of spaces) + * + * @return + * Value with space characters changed to underscores. + * + * @see FeedsElement::getValue() + */ + public function getLocalValue() { + return str_replace(' ', '_', $this->getValue()); + } + + /** + * @return + * The content of the referenced resource. + */ + public function getContent() { + feeds_include_library('http_request.inc', 'http_request'); + $result = http_request_get($this->getUrlEncodedValue()); + if ($result->code != 200) { + throw new Exception(t('Download of @url failed with code !code.', array('@url' => $this->getUrlEncodedValue(), '!code' => $result->code))); + } + return $result->data; + } + + /** + * Get a Drupal file object of the enclosed resource, download if necessary. + * + * @param $destination + * The path or uri specifying the target directory in which the file is + * expected. Don't use trailing slashes unless it's a streamwrapper scheme. + * + * @return + * A Drupal temporary file object of the enclosed resource. + * + * @throws Exception + * If file object could not be created. + */ + public function getFile($destination) { + + if ($this->getValue()) { + // Prepare destination directory. + file_prepare_directory($destination, FILE_MODIFY_PERMISSIONS | FILE_CREATE_DIRECTORY); + // Copy or save file depending on whether it is remote or local. + if (drupal_realpath($this->getValue())) { + $file = new stdClass(); + $file->uid = 0; + $file->uri = $this->getValue(); + $file->filemime = $this->mime_type; + $file->filename = basename($file->uri); + if (dirname($file->uri) != $destination) { + $file = file_copy($file, $destination); + } + else { + // If file is not to be copied, check whether file already exists, + // as file_save() won't do that for us (compare file_copy() and + // file_save()) + $existing_files = file_load_multiple(array(), array('uri' => $file->uri)); + if (count($existing_files)) { + $existing = reset($existing_files); + $file->fid = $existing->fid; + $file->filename = $existing->filename; + } + file_save($file); + } + } + else { + $filename = basename($this->getLocalValue()); + if (module_exists('transliteration')) { + require_once drupal_get_path('module', 'transliteration') . '/transliteration.inc'; + $filename = transliteration_clean_filename($filename); + } + if (file_uri_target($destination)) { + $destination = trim($destination, '/') . '/'; + } + try { + $file = file_save_data($this->getContent(), $destination . $filename); + } + catch (Exception $e) { + watchdog_exception('Feeds', $e, nl2br(check_plain($e))); + } + } + + // We couldn't make sense of this enclosure, throw an exception. + if (!$file) { + throw new Exception(t('Invalid enclosure %enclosure', array('%enclosure' => $this->getValue()))); + } + } + return $file; + } +} + +/** + * Defines a date element of a parsed result (including ranges, repeat). + */ +class FeedsDateTimeElement extends FeedsElement { + + // Start date and end date. + public $start; + public $end; + + /** + * Constructor. + * + * @param $start + * A FeedsDateTime object or a date as accepted by FeedsDateTime. + * @param $end + * A FeedsDateTime object or a date as accepted by FeedsDateTime. + * @param $tz + * A PHP DateTimeZone object. + */ + public function __construct($start = NULL, $end = NULL, $tz = NULL) { + $this->start = (!isset($start) || ($start instanceof FeedsDateTime)) ? $start : new FeedsDateTime($start, $tz); + $this->end = (!isset($end) || ($end instanceof FeedsDateTime)) ? $end : new FeedsDateTime($end, $tz); + } + + /** + * Override FeedsElement::getValue(). + * + * @return + * The UNIX timestamp of this object's start date. Return value is + * technically a string but will only contain numeric values. + */ + public function getValue() { + if ($this->start) { + return $this->start->format('U'); + } + return '0'; + } + + /** + * Merge this field with another. Most stuff goes down when merging the two + * sub-dates. + * + * @see FeedsDateTime + */ + public function merge(FeedsDateTimeElement $other) { + $this2 = clone $this; + if ($this->start && $other->start) { + $this2->start = $this->start->merge($other->start); + } + elseif ($other->start) { + $this2->start = clone $other->start; + } + elseif ($this->start) { + $this2->start = clone $this->start; + } + + if ($this->end && $other->end) { + $this2->end = $this->end->merge($other->end); + } + elseif ($other->end) { + $this2->end = clone $other->end; + } + elseif ($this->end) { + $this2->end = clone $this->end; + } + return $this2; + } + + /** + * Helper method for buildDateField(). Build a FeedsDateTimeElement object + * from a standard formatted node. + */ + protected static function readDateField($entity, $field_name) { + $ret = new FeedsDateTimeElement(); + if (isset($entity->{$field_name}['und'][0]['date']) && $entity->{$field_name}['und'][0]['date'] instanceof FeedsDateTime) { + $ret->start = $entity->{$field_name}['und'][0]['date']; + } + if (isset($entity->{$field_name}['und'][0]['date2']) && $entity->{$field_name}['und'][0]['date2'] instanceof FeedsDateTime) { + $ret->end = $entity->{$field_name}['und'][0]['date2']; + } + return $ret; + } + + /** + * Build a entity's date field from our object. + * + * @param $entity + * The entity to build the date field on. + * @param $field_name + * The name of the field to build. + */ + public function buildDateField($entity, $field_name) { + $info = field_info_field($field_name); + + $oldfield = FeedsDateTimeElement::readDateField($entity, $field_name); + // Merge with any preexisting objects on the field; we take precedence. + $oldfield = $this->merge($oldfield); + $use_start = $oldfield->start; + $use_end = $oldfield->end; + + // Set timezone if not already in the FeedsDateTime object + $to_tz = date_get_timezone($info['settings']['tz_handling'], date_default_timezone()); + $temp = new FeedsDateTime(NULL, new DateTimeZone($to_tz)); + + $db_tz = ''; + if ($use_start) { + $use_start = $use_start->merge($temp); + if (!date_timezone_is_valid($use_start->getTimezone()->getName())) { + $use_start->setTimezone(new DateTimeZone("UTC")); + } + $db_tz = date_get_timezone_db($info['settings']['tz_handling'], $use_start->getTimezone()->getName()); + } + if ($use_end) { + $use_end = $use_end->merge($temp); + if (!date_timezone_is_valid($use_end->getTimezone()->getName())) { + $use_end->setTimezone(new DateTimeZone("UTC")); + } + if (!$db_tz) { + $db_tz = date_get_timezone_db($info['settings']['tz_handling'], $use_end->getTimezone()->getName()); + } + } + if (!$db_tz) { + return; + } + + $db_tz = new DateTimeZone($db_tz); + if (!isset($entity->{$field_name})) { + $entity->{$field_name} = array('und' => array()); + } + if ($use_start) { + $entity->{$field_name}['und'][0]['timezone'] = $use_start->getTimezone()->getName(); + $entity->{$field_name}['und'][0]['offset'] = $use_start->getOffset(); + $use_start->setTimezone($db_tz); + $entity->{$field_name}['und'][0]['date'] = $use_start; + /** + * @todo the date_type_format line could be simplified based upon a patch + * DO issue #259308 could affect this, follow up on at some point. + * Without this, all granularity info is lost. + * $use_start->format(date_type_format($field['type'], $use_start->granularity)); + */ + $entity->{$field_name}['und'][0]['value'] = $use_start->format(date_type_format($info['type'])); + } + if ($use_end) { + // Don't ever use end to set timezone (for now) + $entity->{$field_name}['und'][0]['offset2'] = $use_end->getOffset(); + $use_end->setTimezone($db_tz); + $entity->{$field_name}['und'][0]['date2'] = $use_end; + $entity->{$field_name}['und'][0]['value2'] = $use_end->format(date_type_format($info['type'])); + } + } +} + +/** + * Extend PHP DateTime class with granularity handling, merge functionality and + * slightly more flexible initialization parameters. + * + * This class is a Drupal independent extension of the >= PHP 5.2 DateTime + * class. + * + * @see FeedsDateTimeElement + */ +class FeedsDateTime extends DateTime { + public $granularity = array(); + protected static $allgranularity = array('year', 'month', 'day', 'hour', 'minute', 'second', 'zone'); + private $_serialized_time; + private $_serialized_timezone; + + /** + * Helper function to prepare the object during serialization. + * + * We are extending a core class and core classes cannot be serialized. + * + * Ref: http://bugs.php.net/41334, http://bugs.php.net/39821 + */ + public function __sleep() { + $this->_serialized_time = $this->format('c'); + $this->_serialized_timezone = $this->getTimezone()->getName(); + return array('_serialized_time', '_serialized_timezone'); + } + + /** + * Upon unserializing, we must re-build ourselves using local variables. + */ + public function __wakeup() { + $this->__construct($this->_serialized_time, new DateTimeZone($this->_serialized_timezone)); + } + + /** + * Overridden constructor. + * + * @param $time + * time string, flexible format including timestamp. Invalid formats will + * fall back to 'now'. + * @param $tz + * PHP DateTimeZone object, NULL allowed + */ + public function __construct($time = '', $tz = NULL) { + // Assume UNIX timestamp if numeric. + if (is_numeric($time)) { + // Make sure it's not a simple year + if ((is_string($time) && strlen($time) > 4) || is_int($time)) { + $time = "@" . $time; + } + } + + // PHP < 5.3 doesn't like the GMT- notation for parsing timezones. + $time = str_replace("GMT-", "-", $time); + $time = str_replace("GMT+", "+", $time); + + // Some PHP 5.2 version's DateTime class chokes on invalid dates. + if (!strtotime($time)) { + $time = 'now'; + } + + // Create and set time zone separately, PHP 5.2.6 does not respect time zone + // argument in __construct(). + parent::__construct($time); + $tz = $tz ? $tz : new DateTimeZone("UTC"); + $this->setTimeZone($tz); + + // Verify that timezone has not been specified as an offset. + if (!preg_match('/[a-zA-Z]/', $this->getTimezone()->getName())) { + $this->setTimezone(new DateTimeZone("UTC")); + } + + // Finally set granularity. + $this->setGranularityFromTime($time, $tz); + } + + /** + * This function will keep this object's values by default. + */ + public function merge(FeedsDateTime $other) { + $other_tz = $other->getTimezone(); + $this_tz = $this->getTimezone(); + // Figure out which timezone to use for combination. + $use_tz = ($this->hasGranularity('zone') || !$other->hasGranularity('zone')) ? $this_tz : $other_tz; + + $this2 = clone $this; + $this2->setTimezone($use_tz); + $other->setTimezone($use_tz); + $val = $this2->toArray(); + $otherval = $other->toArray(); + foreach (self::$allgranularity as $g) { + if ($other->hasGranularity($g) && !$this2->hasGranularity($g)) { + // The other class has a property we don't; steal it. + $this2->addGranularity($g); + $val[$g] = $otherval[$g]; + } + } + $other->setTimezone($other_tz); + + $this2->setDate($val['year'], $val['month'], $val['day']); + $this2->setTime($val['hour'], $val['minute'], $val['second']); + return $this2; + } + + /** + * Overrides default DateTime function. Only changes output values if + * actually had time granularity. This should be used as a "converter" for + * output, to switch tzs. + * + * In order to set a timezone for a datetime that doesn't have such + * granularity, merge() it with one that does. + */ + public function setTimezone($tz, $force = FALSE) { + // PHP 5.2.6 has a fatal error when setting a date's timezone to itself. + // http://bugs.php.net/bug.php?id=45038 + if (version_compare(PHP_VERSION, '5.2.7', '<') && $tz == $this->getTimezone()) { + $tz = new DateTimeZone($tz->getName()); + } + + if (!$this->hasTime() || !$this->hasGranularity('zone') || $force) { + // this has no time or timezone granularity, so timezone doesn't mean much + // We set the timezone using the method, which will change the day/hour, but then we switch back + $arr = $this->toArray(); + parent::setTimezone($tz); + $this->setDate($arr['year'], $arr['month'], $arr['day']); + $this->setTime($arr['hour'], $arr['minute'], $arr['second']); + return; + } + parent::setTimezone($tz); + } + + /** + * Safely adds a granularity entry to the array. + */ + public function addGranularity($g) { + $this->granularity[] = $g; + $this->granularity = array_unique($this->granularity); + } + + /** + * Removes a granularity entry from the array. + */ + public function removeGranularity($g) { + if ($key = array_search($g, $this->granularity)) { + unset($this->granularity[$key]); + } + } + + /** + * Checks granularity array for a given entry. + */ + public function hasGranularity($g) { + return in_array($g, $this->granularity); + } + + /** + * Returns whether this object has time set. Used primarily for timezone + * conversion and fomratting. + * + * @todo currently very simplistic, but effective, see usage + */ + public function hasTime() { + return $this->hasGranularity('hour'); + } + + /** + * Protected function to find the granularity given by the arguments to the + * constructor. + */ + protected function setGranularityFromTime($time, $tz) { + $this->granularity = array(); + $temp = date_parse($time); + // This PHP method currently doesn't have resolution down to seconds, so if + // there is some time, all will be set. + foreach (self::$allgranularity AS $g) { + if ((isset($temp[$g]) && is_numeric($temp[$g])) || ($g == 'zone' && (isset($temp['zone_type']) && $temp['zone_type'] > 0))) { + $this->granularity[] = $g; + } + } + if ($tz) { + $this->addGranularity('zone'); + } + } + + /** + * Helper to return all standard date parts in an array. + */ + protected function toArray() { + return array('year' => $this->format('Y'), 'month' => $this->format('m'), 'day' => $this->format('d'), 'hour' => $this->format('H'), 'minute' => $this->format('i'), 'second' => $this->format('s'), 'zone' => $this->format('e')); + } +} + +/** + * Converts to UNIX time. + * + * @param $date + * A date that is either a string, a FeedsDateTimeElement or a UNIX timestamp. + * @param $default_value + * A default UNIX timestamp to return if $date could not be parsed. + * + * @return + * $date as UNIX time if conversion was successful, $dfeault_value otherwise. + */ +function feeds_to_unixtime($date, $default_value) { + if (is_numeric($date)) { + return $date; + } + elseif (is_string($date) && !empty($date)) { + $date = new FeedsDateTimeElement($date); + return $date->getValue(); + } + elseif ($date instanceof FeedsDateTimeElement) { + return $date->getValue(); + } + return $default_value; +} diff --git a/sites/all/modules/feeds/plugins/FeedsPlugin.inc b/sites/all/modules/feeds/plugins/FeedsPlugin.inc new file mode 100644 index 0000000000000000000000000000000000000000..be833930960023cacd520b95f5b6000fd0ccc29f --- /dev/null +++ b/sites/all/modules/feeds/plugins/FeedsPlugin.inc @@ -0,0 +1,216 @@ +<?php + +/** + * @file + * Definition of FeedsPlugin class. + */ + +/** + * Base class for a fetcher, parser or processor result. + */ +class FeedsResult {} + +/** + * Implement source interface for all plugins. + * + * Note how this class does not attempt to store source information locally. + * Doing this would break the model where source information is represented by + * an object that is being passed into a Feed object and its plugins. + */ +abstract class FeedsPlugin extends FeedsConfigurable implements FeedsSourceInterface { + + /** + * Constructor. + * + * Initialize class variables. + */ + protected function __construct($id) { + parent::__construct($id); + $this->source_config = $this->sourceDefaults(); + } + + /** + * Save changes to the configuration of this object. + * Delegate saving to parent (= Feed) which will collect + * information from this object by way of getConfig() and store it. + */ + public function save() { + feeds_importer($this->id)->save(); + } + + /** + * Returns TRUE if $this->sourceForm() returns a form. + */ + public function hasSourceConfig() { + $form = $this->sourceForm(array()); + return !empty($form); + } + + /** + * Implements FeedsSourceInterface::sourceDefaults(). + */ + public function sourceDefaults() { + $values = array_flip(array_keys($this->sourceForm(array()))); + foreach ($values as $k => $v) { + $values[$k] = ''; + } + return $values; + } + + /** + * Callback methods, exposes source form. + */ + public function sourceForm($source_config) { + return array(); + } + + /** + * Validation handler for sourceForm. + */ + public function sourceFormValidate(&$source_config) {} + + /** + * A source is being saved. + */ + public function sourceSave(FeedsSource $source) {} + + /** + * A source is being deleted. + */ + public function sourceDelete(FeedsSource $source) {} + + /** + * Loads on-behalf implementations from mappers/ directory. + * + * FeedsProcessor::map() does not load from mappers/ as only node and user + * processor ship with on-behalf implementations. + * + * @see FeedsNodeProcessor::map() + * @see FeedsUserProcessor::map() + * + * @todo: Use CTools Plugin API. + */ + protected static function loadMappers() { + static $loaded = FALSE; + if (!$loaded) { + $path = drupal_get_path('module', 'feeds') . '/mappers'; + $files = drupal_system_listing('/.*\.inc$/', $path, 'name', 0); + foreach ($files as $file) { + if (strstr($file->uri, '/mappers/')) { + require_once(DRUPAL_ROOT . '/' . $file->uri); + } + } + } + $loaded = TRUE; + } + + /** + * Get all available plugins. + */ + public static function all() { + ctools_include('plugins'); + $plugins = ctools_get_plugins('feeds', 'plugins'); + + $result = array(); + foreach ($plugins as $key => $info) { + if (!empty($info['hidden'])) { + continue; + } + $result[$key] = $info; + } + + // Sort plugins by name and return. + uasort($result, 'feeds_plugin_compare'); + return $result; + } + + /** + * Determines whether given plugin is derived from given base plugin. + * + * @param $plugin_key + * String that identifies a Feeds plugin key. + * @param $parent_plugin + * String that identifies a Feeds plugin key to be tested against. + * + * @return + * TRUE if $parent_plugin is directly *or indirectly* a parent of $plugin, + * FALSE otherwise. + */ + public static function child($plugin_key, $parent_plugin) { + ctools_include('plugins'); + $plugins = ctools_get_plugins('feeds', 'plugins'); + $info = $plugins[$plugin_key]; + + if (empty($info['handler']['parent'])) { + return FALSE; + } + elseif ($info['handler']['parent'] == $parent_plugin) { + return TRUE; + } + else { + return self::child($info['handler']['parent'], $parent_plugin); + } + } + + /** + * Determines the type of a plugin. + * + * @todo PHP5.3: Implement self::type() and query with $plugin_key::type(). + * + * @param $plugin_key + * String that identifies a Feeds plugin key. + * + * @return + * One of the following values: + * 'fetcher' if the plugin is a fetcher + * 'parser' if the plugin is a parser + * 'processor' if the plugin is a processor + * FALSE otherwise. + */ + public static function typeOf($plugin_key) { + if (self::child($plugin_key, 'FeedsFetcher')) { + return 'fetcher'; + } + elseif (self::child($plugin_key, 'FeedsParser')) { + return 'parser'; + } + elseif (self::child($plugin_key, 'FeedsProcessor')) { + return 'processor'; + } + return FALSE; + } + + /** + * Gets all available plugins of a particular type. + * + * @param $type + * 'fetcher', 'parser' or 'processor' + */ + public static function byType($type) { + $plugins = self::all(); + + $result = array(); + foreach ($plugins as $key => $info) { + if ($type == self::typeOf($key)) { + $result[$key] = $info; + } + } + return $result; + } +} + +/** + * Used when a plugin is missing. + */ +class FeedsMissingPlugin extends FeedsPlugin { + public function menuItem() { + return array(); + } +} + +/** + * Sort callback for FeedsPlugin::all(). + */ +function feeds_plugin_compare($a, $b) { + return strcasecmp($a['name'], $b['name']); +} diff --git a/sites/all/modules/feeds/plugins/FeedsProcessor.inc b/sites/all/modules/feeds/plugins/FeedsProcessor.inc new file mode 100644 index 0000000000000000000000000000000000000000..80e54d5791811f918d1ed2ce447cbc0ec6c7c369 --- /dev/null +++ b/sites/all/modules/feeds/plugins/FeedsProcessor.inc @@ -0,0 +1,705 @@ +<?php + +/** + * @file + * Contains FeedsProcessor and related classes. + */ + +// Update mode for existing items. +define('FEEDS_SKIP_EXISTING', 0); +define('FEEDS_REPLACE_EXISTING', 1); +define('FEEDS_UPDATE_EXISTING', 2); + +// Default limit for creating items on a page load, not respected by all +// processors. +define('FEEDS_PROCESS_LIMIT', 50); + +/** + * Thrown if a validation fails. + */ +class FeedsValidationException extends Exception {} + +/** + * Thrown if a an access check fails. + */ +class FeedsAccessException extends Exception {} + +/** + * Abstract class, defines interface for processors. + */ +abstract class FeedsProcessor extends FeedsPlugin { + /** + * @defgroup entity_api_wrapper Entity API wrapper. + */ + + /** + * Entity type this processor operates on. + */ + public abstract function entityType(); + + /** + * Create a new entity. + * + * @param $source + * The feeds source that spawns this entity. + * + * @return + * A new entity object. + */ + protected abstract function newEntity(FeedsSource $source); + + /** + * Load an existing entity. + * + * @param $source + * The feeds source that spawns this entity. + * @param $entity_id + * The unique id of the entity that should be loaded. + * + * @return + * A new entity object. + */ + protected abstract function entityLoad(FeedsSource $source, $entity_id); + + /** + * Validate an entity. + * + * @throws FeedsValidationException $e + * If validation fails. + */ + protected function entityValidate($entity) {} + + /** + * Access check for saving an enity. + * + * @param $entity + * Entity to be saved. + * + * @throws FeedsAccessException $e + * If the access check fails. + */ + protected function entitySaveAccess($entity) {} + + /** + * Save an entity. + * + * @param $entity + * Entity to be saved. + */ + protected abstract function entitySave($entity); + + /** + * Delete a series of entities. + * + * @param $entity_ids + * Array of unique identity ids to be deleted. + */ + protected abstract function entityDeleteMultiple($entity_ids); + + /** + * Wrap entity_get_info() into a method so that extending classes can override + * it and more entity information. Allowed additional keys: + * + * 'label plural' ... the plural label of an entity type. + */ + protected function entityInfo() { + return entity_get_info($this->entityType()); + } + + /** + * @} + */ + + /** + * Process the result of the parsing stage. + * + * @param FeedsSource $source + * Source information about this import. + * @param FeedsParserResult $parser_result + * The result of the parsing stage. + */ + public function process(FeedsSource $source, FeedsParserResult $parser_result) { + $state = $source->state(FEEDS_PROCESS); + + while ($item = $parser_result->shiftItem()) { + + // Check if this item already exists. + $entity_id = $this->existingEntityId($source, $parser_result); + $skip_existing = $this->config['update_existing'] == FEEDS_SKIP_EXISTING; + + // If it exists, and we are not updating, pass onto the next item. + if ($entity_id && $skip_existing) { + continue; + } + + $hash = $this->hash($item); + $changed = ($hash !== $this->getHash($entity_id)); + $force_update = $this->config['skip_hash_check']; + + // Do not proceed if the item exists, has not changed, and we're not + // forcing the update. + if ($entity_id && !$changed && !$force_update) { + continue; + } + + try { + + // Build a new entity. + if (empty($entity_id)) { + $entity = $this->newEntity($source); + $this->newItemInfo($entity, $source->feed_nid, $hash); + } + + // Load an existing entity. + else { + $entity = $this->entityLoad($source, $entity_id); + + // The feeds_item table is always updated with the info for the most recently processed entity. + // The only carryover is the entity_id. + $this->newItemInfo($entity, $source->feed_nid, $hash); + $entity->feeds_item->entity_id = $entity_id; + } + + // Set property and field values. + $this->map($source, $parser_result, $entity); + $this->entityValidate($entity); + + // Allow modules to alter the entity before saving. + module_invoke_all('feeds_presave', $source, $entity, $item); + if (module_exists('rules')) { + rules_invoke_event('feeds_import_'. $source->importer()->id, $entity); + } + + // Enable modules to skip saving at all. + if (!empty($entity->feeds_item->skip)) { + continue; + } + + // This will throw an exception on failure. + $this->entitySaveAccess($entity); + $this->entitySave($entity); + + // Track progress. + if (empty($entity_id)) { + $state->created++; + } + else { + $state->updated++; + } + } + + // Something bad happened, log it. + catch (Exception $e) { + $state->failed++; + drupal_set_message($e->getMessage(), 'warning'); + $message = $e->getMessage(); + $message .= '<h3>Original item</h3>'; + $message .= '<pre>' . var_export($item, TRUE) . '</pre>'; + $message .= '<h3>Entity</h3>'; + $message .= '<pre>' . var_export($entity, TRUE) . '</pre>'; + $source->log('import', $message, array(), WATCHDOG_ERROR); + } + } + + // Set messages if we're done. + if ($source->progressImporting() != FEEDS_BATCH_COMPLETE) { + return; + } + $info = $this->entityInfo(); + $tokens = array( + '@entity' => strtolower($info['label']), + '@entities' => strtolower($info['label plural']), + ); + $messages = array(); + if ($state->created) { + $messages[] = array( + 'message' => format_plural( + $state->created, + 'Created @number @entity.', + 'Created @number @entities.', + array('@number' => $state->created) + $tokens + ), + ); + } + if ($state->updated) { + $messages[] = array( + 'message' => format_plural( + $state->updated, + 'Updated @number @entity.', + 'Updated @number @entities.', + array('@number' => $state->updated) + $tokens + ), + ); + } + if ($state->failed) { + $messages[] = array( + 'message' => format_plural( + $state->failed, + 'Failed importing @number @entity.', + 'Failed importing @number @entities.', + array('@number' => $state->failed) + $tokens + ), + 'level' => WATCHDOG_ERROR, + ); + } + if (empty($messages)) { + $messages[] = array( + 'message' => t('There are no new @entities.', array('@entities' => strtolower($info['label plural']))), + ); + } + foreach ($messages as $message) { + drupal_set_message($message['message']); + $source->log('import', $message['message'], array(), isset($message['level']) ? $message['level'] : WATCHDOG_INFO); + } + } + + /** + * Remove all stored results or stored results up to a certain time for a + * source. + * + * @param FeedsSource $source + * Source information for this expiry. Implementers should only delete items + * pertaining to this source. The preferred way of determining whether an + * item pertains to a certain souce is by using $source->feed_nid. It is the + * processor's responsibility to store the feed_nid of an imported item in + * the processing stage. + */ + public function clear(FeedsSource $source) { + $state = $source->state(FEEDS_PROCESS_CLEAR); + + // Build base select statement. + $info = $this->entityInfo(); + $select = db_select($info['base table'], 'e'); + $select->addField('e', $info['entity keys']['id'], 'entity_id'); + $select->join( + 'feeds_item', + 'fi', + "e.{$info['entity keys']['id']} = fi.entity_id AND fi.entity_type = '{$this->entityType()}'"); + $select->condition('fi.id', $this->id); + $select->condition('fi.feed_nid', $source->feed_nid); + + // If there is no total, query it. + if (!$state->total) { + $state->total = $select->countQuery() + ->execute() + ->fetchField(); + } + + // Delete a batch of entities. + $entities = $select->range(0, $this->getLimit())->execute(); + $entity_ids = array(); + foreach ($entities as $entity) { + $entity_ids[$entity->entity_id] = $entity->entity_id; + } + $this->entityDeleteMultiple($entity_ids); + + // Report progress, take into account that we may not have deleted as + // many items as we have counted at first. + if (count($entity_ids)) { + $state->deleted += count($entity_ids); + $state->progress($state->total, $state->deleted); + } + else { + $state->progress($state->total, $state->total); + } + + // Report results when done. + if ($source->progressClearing() == FEEDS_BATCH_COMPLETE) { + if ($state->deleted) { + $message = format_plural( + $state->deleted, + 'Deleted @number @entity', + 'Deleted @number @entities', + array( + '@number' => $state->deleted, + '@entity' => strtolower($info['label']), + '@entities' => strtolower($info['label plural']), + ) + ); + $source->log('clear', $message, array(), WATCHDOG_INFO); + drupal_set_message($message); + } + else { + drupal_set_message(t('There are no @entities to be deleted.', array('@entities' => $info['label plural']))); + } + } + } + + /* + * Report number of items that can be processed per call. + * + * 0 means 'unlimited'. + * + * If a number other than 0 is given, Feeds parsers that support batching + * will only deliver this limit to the processor. + * + * @see FeedsSource::getLimit() + * @see FeedsCSVParser::parse() + */ + public function getLimit() { + return variable_get('feeds_process_limit', FEEDS_PROCESS_LIMIT); + } + + /** + * Delete feed items younger than now - $time. Do not invoke expire on a + * processor directly, but use FeedsImporter::expire() instead. + * + * @see FeedsImporter::expire(). + * @see FeedsDataProcessor::expire(). + * + * @param $time + * If implemented, all items produced by this configuration that are older + * than REQUEST_TIME - $time should be deleted. + * If $time === NULL processor should use internal configuration. + * + * @return + * FEEDS_BATCH_COMPLETE if all items have been processed, a float between 0 + * and 0.99* indicating progress otherwise. + */ + public function expire($time = NULL) { + return FEEDS_BATCH_COMPLETE; + } + + /** + * Counts the number of items imported by this processor. + */ + public function itemCount(FeedsSource $source) { + return db_query("SELECT count(*) FROM {feeds_item} WHERE id = :id AND entity_type = :entity_type AND feed_nid = :feed_nid", array(':id' => $this->id, ':entity_type' => $this->entityType(), ':feed_nid' => $source->feed_nid))->fetchField(); + } + + /** + * Execute mapping on an item. + * + * This method encapsulates the central mapping functionality. When an item is + * processed, it is passed through map() where the properties of $source_item + * are mapped onto $target_item following the processor's mapping + * configuration. + * + * For each mapping FeedsParser::getSourceElement() is executed to retrieve + * the source element, then FeedsProcessor::setTargetElement() is invoked + * to populate the target item properly. Alternatively a + * hook_x_targets_alter() may have specified a callback for a mapping target + * in which case the callback is asked to populate the target item instead of + * FeedsProcessor::setTargetElement(). + * + * @ingroup mappingapi + * + * @see hook_feeds_parser_sources_alter() + * @see hook_feeds_data_processor_targets_alter() + * @see hook_feeds_node_processor_targets_alter() + * @see hook_feeds_term_processor_targets_alter() + * @see hook_feeds_user_processor_targets_alter() + */ + protected function map(FeedsSource $source, FeedsParserResult $result, $target_item = NULL) { + + // Static cache $targets as getMappingTargets() may be an expensive method. + static $sources; + if (!isset($sources[$this->id])) { + $sources[$this->id] = feeds_importer($this->id)->parser->getMappingSources(); + } + static $targets; + if (!isset($targets[$this->id])) { + $targets[$this->id] = $this->getMappingTargets(); + } + $parser = feeds_importer($this->id)->parser; + if (empty($target_item)) { + $target_item = array(); + } + + // Many mappers add to existing fields rather than replacing them. Hence we + // need to clear target elements of each item before mapping in case we are + // mapping on a prepopulated item such as an existing node. + foreach ($this->config['mappings'] as $mapping) { + if (isset($targets[$this->id][$mapping['target']]['real_target'])) { + unset($target_item->{$targets[$this->id][$mapping['target']]['real_target']}); + } + elseif (isset($target_item->{$mapping['target']})) { + unset($target_item->{$mapping['target']}); + } + } + + /* + This is where the actual mapping happens: For every mapping we envoke + the parser's getSourceElement() method to retrieve the value of the source + element and pass it to the processor's setTargetElement() to stick it + on the right place of the target item. + + If the mapping specifies a callback method, use the callback instead of + setTargetElement(). + */ + self::loadMappers(); + foreach ($this->config['mappings'] as $mapping) { + // Retrieve source element's value from parser. + if (isset($sources[$this->id][$mapping['source']]) && + is_array($sources[$this->id][$mapping['source']]) && + isset($sources[$this->id][$mapping['source']]['callback']) && + function_exists($sources[$this->id][$mapping['source']]['callback'])) { + $callback = $sources[$this->id][$mapping['source']]['callback']; + $value = $callback($source, $result, $mapping['source']); + } + else { + $value = $parser->getSourceElement($source, $result, $mapping['source']); + } + + // Map the source element's value to the target. + if (isset($targets[$this->id][$mapping['target']]) && + is_array($targets[$this->id][$mapping['target']]) && + isset($targets[$this->id][$mapping['target']]['callback']) && + function_exists($targets[$this->id][$mapping['target']]['callback'])) { + $callback = $targets[$this->id][$mapping['target']]['callback']; + $callback($source, $target_item, $mapping['target'], $value, $mapping); + } + else { + $this->setTargetElement($source, $target_item, $mapping['target'], $value, $mapping); + } + } + return $target_item; + } + + /** + * Per default, don't support expiry. If processor supports expiry of imported + * items, return the time after which items should be removed. + */ + public function expiryTime() { + return FEEDS_EXPIRE_NEVER; + } + + /** + * Declare default configuration. + */ + public function configDefaults() { + return array( + 'mappings' => array(), + 'update_existing' => FEEDS_SKIP_EXISTING, + 'input_format' => NULL, + 'skip_hash_check' => FALSE, + ); + } + + /** + * Overrides parent::configForm(). + */ + public function configForm(&$form_state) { + $info = $this->entityInfo(); + $form = array(); + $tokens = array('@entities' => strtolower($info['label plural'])); + $form['update_existing'] = array( + '#type' => 'radios', + '#title' => t('Update existing @entities', $tokens), + '#description' => + t('Existing @entities will be determined using mappings that are a "unique target".', $tokens), + '#options' => array( + FEEDS_SKIP_EXISTING => t('Do not update existing @entities', $tokens), + FEEDS_UPDATE_EXISTING => t('Update existing @entities', $tokens), + ), + '#default_value' => $this->config['update_existing'], + ); + global $user; + $formats = filter_formats($user); + foreach ($formats as $format) { + $format_options[$format->format] = $format->name; + } + $form['skip_hash_check'] = array( + '#type' => 'checkbox', + '#title' => t('Skip hash check'), + '#description' => t('Force update of items even if item source data did not change.'), + '#default_value' => $this->config['skip_hash_check'], + ); + $form['input_format'] = array( + '#type' => 'select', + '#title' => t('Text format'), + '#description' => t('Select the input format for the body field of the nodes to be created.'), + '#options' => $format_options, + '#default_value' => isset($this->config['input_format']) ? $this->config['input_format'] : 'plain_text', + '#required' => TRUE, + ); + return $form; + } + + /** + * Get mappings. + */ + public function getMappings() { + return isset($this->config['mappings']) ? $this->config['mappings'] : array(); + } + + /** + * Declare possible mapping targets that this processor exposes. + * + * @ingroup mappingapi + * + * @return + * An array of mapping targets. Keys are paths to targets + * separated by ->, values are TRUE if target can be unique, + * FALSE otherwise. + */ + public function getMappingTargets() { + return array( + 'url' => array( + 'name' => t('URL'), + 'description' => t('The external URL of the item. E. g. the feed item URL in the case of a syndication feed. May be unique.'), + 'optional_unique' => TRUE, + ), + 'guid' => array( + 'name' => t('GUID'), + 'description' => t('The globally unique identifier of the item. E. g. the feed item GUID in the case of a syndication feed. May be unique.'), + 'optional_unique' => TRUE, + ), + ); + } + + /** + * Set a concrete target element. Invoked from FeedsProcessor::map(). + * + * @ingroup mappingapi + */ + public function setTargetElement(FeedsSource $source, $target_item, $target_element, $value) { + switch ($target_element) { + case 'url': + case 'guid': + $target_item->feeds_item->$target_element = $value; + break; + default: + $target_item->$target_element = $value; + break; + } + } + + /** + * Retrieve the target entity's existing id if available. Otherwise return 0. + * + * @ingroup mappingapi + * + * @param FeedsSource $source + * The source information about this import. + * @param $result + * A FeedsParserResult object. + * + * @return + * The serial id of an entity if found, 0 otherwise. + */ + protected function existingEntityId(FeedsSource $source, FeedsParserResult $result) { + $query = db_select('feeds_item') + ->fields('feeds_item', array('entity_id')) + ->condition('feed_nid', $source->feed_nid) + ->condition('entity_type', $this->entityType()) + ->condition('id', $source->id); + + // Iterate through all unique targets and test whether they do already + // exist in the database. + foreach ($this->uniqueTargets($source, $result) as $target => $value) { + switch ($target) { + case 'url': + $entity_id = $query->condition('url', $value)->execute()->fetchField(); + break; + case 'guid': + $entity_id = $query->condition('guid', $value)->execute()->fetchField(); + break; + } + if (isset($entity_id)) { + // Return with the content id found. + return $entity_id; + } + } + return 0; + } + + + /** + * Utility function that iterates over a target array and retrieves all + * sources that are unique. + * + * @param $batch + * A FeedsImportBatch. + * + * @return + * An array where the keys are target field names and the values are the + * elements from the source item mapped to these targets. + */ + protected function uniqueTargets(FeedsSource $source, FeedsParserResult $result) { + $parser = feeds_importer($this->id)->parser; + $targets = array(); + foreach ($this->config['mappings'] as $mapping) { + if ($mapping['unique']) { + // Invoke the parser's getSourceElement to retrieve the value for this + // mapping's source. + $targets[$mapping['target']] = $parser->getSourceElement($source, $result, $mapping['source']); + } + } + return $targets; + } + + /** + * Adds Feeds specific information on $entity->feeds_item. + * + * @param $entity + * The entity object to be populated with new item info. + * @param $feed_nid + * The feed nid of the source that produces this entity. + * @param $hash + * The fingerprint of the source item. + */ + protected function newItemInfo($entity, $feed_nid, $hash = '') { + $entity->feeds_item = new stdClass(); + $entity->feeds_item->entity_id = 0; + $entity->feeds_item->entity_type = $this->entityType(); + $entity->feeds_item->id = $this->id; + $entity->feeds_item->feed_nid = $feed_nid; + $entity->feeds_item->imported = REQUEST_TIME; + $entity->feeds_item->hash = $hash; + $entity->feeds_item->url = ''; + $entity->feeds_item->guid = ''; + } + + /** + * Loads existing entity information and places it on $entity->feeds_item. + * + * @param $entity + * The entity object to load item info for. Id key must be present. + * + * @return + * TRUE if item info could be loaded, false if not. + */ + protected function loadItemInfo($entity) { + $entity_info = entity_get_info($this->entityType()); + $key = $entity_info['entity keys']['id']; + if ($item_info = feeds_item_info_load($this->entityType(), $entity->$key)) { + $entity->feeds_item = $item_info; + return TRUE; + } + return FALSE; + } + + /** + * Create MD5 hash of item and mappings array. + * + * Include mappings as a change in mappings may have an affect on the item + * produced. + * + * @return Always returns a hash, even with empty, NULL, FALSE: + * Empty arrays return 40cd750bba9870f18aada2478b24840a + * Empty/NULL/FALSE strings return d41d8cd98f00b204e9800998ecf8427e + */ + protected function hash($item) { + static $serialized_mappings; + if (!$serialized_mappings) { + $serialized_mappings = serialize($this->config['mappings']); + } + return hash('md5', serialize($item) . $serialized_mappings); + } + + /** + * Retrieves the MD5 hash of $entity_id from the database. + * + * @return string + * Empty string if no item is found, hash otherwise. + */ + protected function getHash($entity_id) { + + if ($hash = db_query("SELECT hash FROM {feeds_item} WHERE entity_type = :type AND entity_id = :id", array(':type' => $this->entityType(), ':id' => $entity_id))->fetchField()) { + // Return with the hash. + return $hash; + } + return ''; + } +} diff --git a/sites/all/modules/feeds/plugins/FeedsSimplePieParser.inc b/sites/all/modules/feeds/plugins/FeedsSimplePieParser.inc new file mode 100644 index 0000000000000000000000000000000000000000..cadb6b81605f675cce4fc6fcfe192ca8e9b664a5 --- /dev/null +++ b/sites/all/modules/feeds/plugins/FeedsSimplePieParser.inc @@ -0,0 +1,240 @@ +<?php + +/** + * @file + * Contains FeedsSimplePieParser and related classes. + */ + +/** + * Adapter to present SimplePie_Enclosure as FeedsEnclosure object. + */ +class FeedsSimplePieEnclosure extends FeedsEnclosure { + protected $simplepie_enclosure; + private $_serialized_simplepie_enclosure; + + /** + * Constructor requires SimplePie enclosure object. + */ + function __construct(SimplePie_Enclosure $enclosure) { + $this->simplepie_enclosure = $enclosure; + } + + /** + * Serialization helper. + * + * Handle the simplepie enclosure class seperately ourselves. + */ + public function __sleep() { + $this->_serialized_simplepie_enclosure = serialize($this->simplepie_enclosure); + return array('_serialized_simplepie_enclosure'); + } + + /** + * Unserialization helper. + * + * Ensure that the simplepie class definitions are loaded for the enclosure when unserializing. + */ + public function __wakeup() { + feeds_include_simplepie(); + $this->simplepie_enclosure = unserialize($this->_serialized_simplepie_enclosure); + } + + /** + * Override parent::getValue(). + */ + public function getValue() { + return $this->simplepie_enclosure->get_link(); + } + + /** + * Override parent::getMIMEType(). + */ + public function getMIMEType() { + return $this->simplepie_enclosure->get_real_type(); + } +} + +/** + * Class definition for Common Syndication Parser. + * + * Parses RSS and Atom feeds. + */ +class FeedsSimplePieParser extends FeedsParser { + + /** + * Implements FeedsParser::parse(). + */ + public function parse(FeedsSource $source, FeedsFetcherResult $fetcher_result) { + feeds_include_simplepie(); + + // Please be quiet SimplePie. + $level = error_reporting(); + error_reporting($level ^ E_DEPRECATED ^ E_STRICT); + + // Initialize SimplePie. + $parser = new SimplePie(); + $parser->set_raw_data($fetcher_result->getRaw()); + $parser->set_stupidly_fast(TRUE); + $parser->encode_instead_of_strip(FALSE); + // @todo Is caching effective when we pass in raw data? + $parser->enable_cache(TRUE); + $parser->set_cache_location($this->cacheDirectory()); + $parser->init(); + + // Construct the standard form of the parsed feed + $result = new FeedsParserResult(); + $result->title = html_entity_decode(($title = $parser->get_title()) ? $title : $this->createTitle($parser->get_description())); + $result->description = $parser->get_description(); + $result->link = html_entity_decode($parser->get_link()); + + $items_num = $parser->get_item_quantity(); + for ($i = 0; $i < $items_num; $i++) { + $item = array(); + $simplepie_item = $parser->get_item($i); + $item['title'] = html_entity_decode(($title = $simplepie_item->get_title()) ? $title : $this->createTitle($simplepie_item->get_content())); + $item['description'] = $simplepie_item->get_content(); + $item['url'] = html_entity_decode($simplepie_item->get_link()); + // Use UNIX time. If no date is defined, fall back to REQUEST_TIME. + $item['timestamp'] = $simplepie_item->get_date("U"); + if (empty($item['timestamp'])) { + $item['timestamp'] = REQUEST_TIME; + } + $item['guid'] = $simplepie_item->get_id(); + // Use URL as GUID if there is no GUID. + if (empty($item['guid'])) { + $item['guid'] = $item['url']; + } + $author = $simplepie_item->get_author(); + $item['author_name'] = isset($author->name) ? html_entity_decode($author->name) : ''; + $item['author_link'] = isset($author->link) ? $author->link : ''; + $item['author_email'] = isset($author->email) ? $author->email : ''; + // Enclosures + $enclosures = $simplepie_item->get_enclosures(); + if (is_array($enclosures)) { + foreach ($enclosures as $enclosure) { + $item['enclosures'][] = new FeedsSimplePieEnclosure($enclosure); + } + } + // Location + $latitude = $simplepie_item->get_latitude(); + $longitude = $simplepie_item->get_longitude(); + if (!is_null($latitude) && !is_null($longitude)) { + $item['location_latitude'][] = $latitude; + $item['location_longitude'][] = $longitude; + } + // Extract tags related to the item + $simplepie_tags = $simplepie_item->get_categories(); + $tags = array(); + $domains = array(); + if (count($simplepie_tags) > 0) { + foreach ($simplepie_tags as $tag) { + $tags[] = (string) $tag->term; + $domain = (string) $tag->get_scheme(); + if (!empty($domain)) { + if (!isset($domains[$domain])) { + $domains[$domain] = array(); + } + $domains[$domain][] = count($tags) - 1; + } + } + } + $item['domains'] = $domains; + $item['tags'] = $tags; + + // Allow parsing to be extended. + $this->parseExtensions($item, $simplepie_item); + $item['raw'] = $simplepie_item->data; + + $result->items[] = $item; + } + // Release parser. + unset($parser); + // Set error reporting back to its previous value. + error_reporting($level); + return $result; + } + + /** + * Allow extension of FeedsSimplePie item parsing. + */ + protected function parseExtensions(&$item, $simplepie_item) {} + + /** + * Return mapping sources. + */ + public function getMappingSources() { + return array( + 'title' => array( + 'name' => t('Title'), + 'description' => t('Title of the feed item.'), + ), + 'description' => array( + 'name' => t('Description'), + 'description' => t('Description of the feed item.'), + ), + 'author_name' => array( + 'name' => t('Author name'), + 'description' => t('Name of the feed item\'s author.'), + ), + 'author_link' => array( + 'name' => t('Author link'), + 'description' => t('Link to the feed item\'s author.'), + ), + 'author_email' => array( + 'name' => t('Author email'), + 'description' => t('Email address of the feed item\'s author.'), + ), + 'timestamp' => array( + 'name' => t('Published date'), + 'description' => t('Published date as UNIX time GMT of the feed item.'), + ), + 'url' => array( + 'name' => t('Item URL (link)'), + 'description' => t('URL of the feed item.'), + ), + 'guid' => array( + 'name' => t('Item GUID'), + 'description' => t('Global Unique Identifier of the feed item.'), + ), + 'tags' => array( + 'name' => t('Categories'), + 'description' => t('An array of categories that have been assigned to the feed item.'), + ), + 'domains' => array( + 'name' => t('Category domains'), + 'description' => t('Domains of the categories.'), + ), + 'location_latitude' => array( + 'name' => t('Latitudes'), + 'description' => t('An array of latitudes assigned to the feed item.'), + ), + 'location_longitude' => array( + 'name' => t('Longitudes'), + 'description' => t('An array of longitudes assigned to the feed item.'), + ), + 'enclosures' => array( + 'name' => t('Enclosures'), + 'description' => t('An array of enclosures attached to the feed item.'), + ), + ) + parent::getMappingSources(); + } + + /** + * Returns cache directory. Creates it if it doesn't exist. + */ + protected function cacheDirectory() { + $directory = 'public://simplepie'; + file_prepare_directory($dir, FILE_CREATE_DIRECTORY | FILE_MODIFY_PERMISSIONS); + return $directory; + } + + /** + * Generate a title from a random text. + */ + protected function createTitle($text = FALSE) { + // Explode to words and use the first 3 words. + $words = preg_split("/[\s,]+/", $text); + $words = array_slice($words, 0, 3); + return implode(' ', $words); + } +} diff --git a/sites/all/modules/feeds/plugins/FeedsSitemapParser.inc b/sites/all/modules/feeds/plugins/FeedsSitemapParser.inc new file mode 100644 index 0000000000000000000000000000000000000000..92885936a4246096cfad5562965bd163361ded91 --- /dev/null +++ b/sites/all/modules/feeds/plugins/FeedsSitemapParser.inc @@ -0,0 +1,62 @@ +<?php + +/** + * @file + * Contains FeedsSitemapParser and related classes. + */ + +/** + * A parser for the Sitemap specification http://www.sitemaps.org/protocol.php + */ +class FeedsSitemapParser extends FeedsParser { + /** + * Implements FeedsParser::parse(). + */ + public function parse(FeedsSource $source, FeedsFetcherResult $fetcher_result) { + // Set time zone to GMT for parsing dates with strtotime(). + $tz = date_default_timezone_get(); + date_default_timezone_set('GMT'); + // Yes, using a DOM parser is a bit inefficient, but will do for now + $xml = new SimpleXMLElement($fetcher_result->getRaw()); + $result = new FeedsParserResult(); + foreach ($xml->url as $url) { + $item = array('url' => (string) $url->loc); + if ($url->lastmod) { + $item['lastmod'] = strtotime($url->lastmod); + } + if ($url->changefreq) { + $item['changefreq'] = (string) $url->changefreq; + } + if ($url->priority) { + $item['priority'] = (string) $url->priority; + } + $result->items[] = $item; + } + date_default_timezone_set($tz); + return $result; + } + + /** + * Implements FeedsParser::getMappingSources(). + */ + public function getMappingSources() { + return array( + 'url' => array( + 'name' => t('Item URL (link)'), + 'description' => t('URL of the feed item.'), + ), + 'lastmod' => array( + 'name' => t('Last modification date'), + 'description' => t('Last modified date as UNIX time GMT of the feed item.'), + ), + 'changefreq' => array( + 'name' => t('Change frequency'), + 'description' => t('How frequently the page is likely to change.'), + ), + 'priority' => array( + 'name' => t('Priority'), + 'description' => t('The priority of this URL relative to other URLs on the site.'), + ), + ) + parent::getMappingSources(); + } +} diff --git a/sites/all/modules/feeds/plugins/FeedsSyndicationParser.inc b/sites/all/modules/feeds/plugins/FeedsSyndicationParser.inc new file mode 100644 index 0000000000000000000000000000000000000000..09b0cb327ba1437d0000cd7488e7ed2e36c463f2 --- /dev/null +++ b/sites/all/modules/feeds/plugins/FeedsSyndicationParser.inc @@ -0,0 +1,81 @@ +<?php + +/** + * @file + * Contains FeedsSyndicationParser and related classes. + */ + +/** + * Class definition for Common Syndication Parser. + * + * Parses RSS and Atom feeds. + */ +class FeedsSyndicationParser extends FeedsParser { + + /** + * Implements FeedsParser::parse(). + */ + public function parse(FeedsSource $source, FeedsFetcherResult $fetcher_result) { + feeds_include_library('common_syndication_parser.inc', 'common_syndication_parser'); + $feed = common_syndication_parser_parse($fetcher_result->getRaw()); + $result = new FeedsParserResult(); + $result->title = $feed['title']; + $result->description = $feed['description']; + $result->link = $feed['link']; + if (is_array($feed['items'])) { + foreach ($feed['items'] as $item) { + if (isset($item['geolocations'])) { + foreach ($item['geolocations'] as $k => $v) { + $item['geolocations'][$k] = new FeedsGeoTermElement($v); + } + } + $result->items[] = $item; + } + } + return $result; + } + + /** + * Return mapping sources. + * + * At a future point, we could expose data type information here, + * storage systems like Data module could use this information to store + * parsed data automatically in fields with a correct field type. + */ + public function getMappingSources() { + return array( + 'title' => array( + 'name' => t('Title'), + 'description' => t('Title of the feed item.'), + ), + 'description' => array( + 'name' => t('Description'), + 'description' => t('Description of the feed item.'), + ), + 'author_name' => array( + 'name' => t('Author name'), + 'description' => t('Name of the feed item\'s author.'), + ), + 'timestamp' => array( + 'name' => t('Published date'), + 'description' => t('Published date as UNIX time GMT of the feed item.'), + ), + 'url' => array( + 'name' => t('Item URL (link)'), + 'description' => t('URL of the feed item.'), + ), + 'guid' => array( + 'name' => t('Item GUID'), + 'description' => t('Global Unique Identifier of the feed item.'), + ), + 'tags' => array( + 'name' => t('Categories'), + 'description' => t('An array of categories that have been assigned to the feed item.'), + ), + 'geolocations' => array( + 'name' => t('Geo Locations'), + 'description' => t('An array of geographic locations with a name and a position.'), + ), + ) + parent::getMappingSources(); + } +} diff --git a/sites/all/modules/feeds/plugins/FeedsTermProcessor.inc b/sites/all/modules/feeds/plugins/FeedsTermProcessor.inc new file mode 100644 index 0000000000000000000000000000000000000000..7721ff01bde909d2063e7d77f566b36013f6c4c9 --- /dev/null +++ b/sites/all/modules/feeds/plugins/FeedsTermProcessor.inc @@ -0,0 +1,245 @@ +<?php + +/** + * @file + * FeedsTermProcessor class. + */ + +/** + * Feeds processor plugin. Create taxonomy terms from feed items. + */ +class FeedsTermProcessor extends FeedsProcessor { + /** + * Define entity type. + */ + public function entityType() { + return 'taxonomy_term'; + } + + /** + * Implements parent::entityInfo(). + */ + protected function entityInfo() { + $info = parent::entityInfo(); + $info['label plural'] = t('Terms'); + return $info; + } + + /** + * Creates a new term in memory and returns it. + */ + protected function newEntity(FeedsSource $source) { + $vocabulary = $this->vocabulary(); + $term = new stdClass(); + $term->vid = $vocabulary->vid; + $term->vocabulary_machine_name = $vocabulary->machine_name; + $term->format = isset($this->config['input_format']) ? $this->config['input_format'] : filter_fallback_format(); + return $term; + } + + /** + * Loads an existing term. + */ + protected function entityLoad(FeedsSource $source, $tid) { + return taxonomy_term_load($tid); + } + + /** + * Validates a term. + */ + protected function entityValidate($term) { + if (empty($term->name)) { + throw new FeedsValidationException(t('Term name missing.')); + } + } + + /** + * Saves a term. + * + * We de-array parent fields with only one item. + * This stops leftandright module from freaking out. + */ + protected function entitySave($term) { + if (isset($term->parent)) { + if (is_array($term->parent) && count($term->parent) == 1) { + $term->parent = reset($term->parent); + } + if (isset($term->tid) && ($term->parent == $term->tid || (is_array($term->parent) && in_array($term->tid, $term->parent)))) { + throw new FeedsValidationException(t("A term can't be its own child. GUID:@guid", array('@guid' => $term->feeds_item->guid))); + } + } + taxonomy_term_save($term); + } + + /** + * Deletes a series of terms. + */ + protected function entityDeleteMultiple($tids) { + foreach ($tids as $tid) { + taxonomy_term_delete($tid); + } + } + + /** + * Override parent::configDefaults(). + */ + public function configDefaults() { + return array( + 'vocabulary' => 0, + ) + parent::configDefaults(); + } + + /** + * Override parent::configForm(). + */ + public function configForm(&$form_state) { + $options = array(0 => t('Select a vocabulary')); + foreach (taxonomy_get_vocabularies() as $vocab) { + $options[$vocab->machine_name] = $vocab->name; + } + $form = parent::configForm($form_state); + $form['vocabulary'] = array( + '#type' => 'select', + '#title' => t('Import to vocabulary'), + '#description' => t('Choose the vocabulary to import into. <strong>CAUTION:</strong> when deleting terms through the "Delete items" tab, Feeds will delete <em>all</em> terms from this vocabulary.'), + '#options' => $options, + '#default_value' => $this->config['vocabulary'], + ); + return $form; + } + + /** + * Override parent::configFormValidate(). + */ + public function configFormValidate(&$values) { + if (empty($values['vocabulary'])) { + form_set_error('vocabulary', t('Choose a vocabulary')); + } + } + + /** + * Override setTargetElement to operate on a target item that is a taxonomy term. + */ + public function setTargetElement(FeedsSource $source, $target_term, $target_element, $value) { + switch ($target_element) { + case 'parent': + if (!empty($value)) { + $terms = taxonomy_get_term_by_name($value); + $parent_tid = ''; + foreach ($terms as $term) { + if ($term->vid == $target_term->vid) { + $parent_tid = $term->tid; + } + } + if (!empty($parent_tid)) { + $target_term->parent[] = $parent_tid; + } + else { + $target_term->parent[] = 0; + } + } + else { + $target_term->parent[] = 0; + } + break; + case 'parentguid': + // value is parent_guid field value + $query = db_select('feeds_item') + ->fields('feeds_item', array('entity_id')) + ->condition('entity_type', $this->entityType()); + $parent_tid = $query->condition('guid', $value)->execute()->fetchField(); + $target_term->parent[] = ($parent_tid) ? $parent_tid : 0; + + break; + case 'weight': + if (!empty($value)) { + $weight = intval($value); + } + else { + $weight = 0; + } + $target_term->weight = $weight; + break; + default: + parent::setTargetElement($source, $target_term, $target_element, $value); + break; + } + } + + /** + * Return available mapping targets. + */ + public function getMappingTargets() { + $targets = parent::getMappingTargets(); + $targets += array( + 'name' => array( + 'name' => t('Term name'), + 'description' => t('Name of the taxonomy term.'), + 'optional_unique' => TRUE, + ), + 'parent' => array( + 'name' => t('Parent: Term name'), + 'description' => t('The name of the parent taxonomy term.'), + 'optional_unique' => TRUE, + ), + 'parentguid' => array( + 'name' => t('Parent: GUID'), + 'description' => t('The GUID of the parent taxonomy term.'), + 'optional_unique' => TRUE, + ), + 'weight' => array( + 'name' => t('Term weight'), + 'description' => t('Weight of the taxonomy term.'), + 'optional_unique' => TRUE, + ), + 'description' => array( + 'name' => t('Term description'), + 'description' => t('Description of the taxonomy term.'), + ), + ); + + // Let implementers of hook_feeds_term_processor_targets() add their targets. + try { + self::loadMappers(); + $entity_type = $this->entityType(); + $bundle = $this->vocabulary()->machine_name; + drupal_alter('feeds_processor_targets', $targets, $entity_type, $bundle); + } + catch (Exception $e) { + // Do nothing. + } + return $targets; + } + + /** + * Get id of an existing feed item term if available. + */ + protected function existingEntityId(FeedsSource $source, FeedsParserResult $result) { + if ($tid = parent::existingEntityId($source, $result)) { + return $tid; + } + + // The only possible unique target is name. + foreach ($this->uniqueTargets($source, $result) as $target => $value) { + if ($target == 'name') { + $vocabulary = $this->vocabulary(); + if ($tid = db_query("SELECT tid FROM {taxonomy_term_data} WHERE name = :name AND vid = :vid", array(':name' => $value, ':vid' => $vocabulary->vid))->fetchField()) { + return $tid; + } + } + } + return 0; + } + + /** + * Return vocabulary to map to. + */ + public function vocabulary() { + if (isset($this->config['vocabulary'])) { + if ($vocabulary = taxonomy_vocabulary_machine_name_load($this->config['vocabulary'])) { + return $vocabulary; + } + } + throw new Exception(t('No vocabulary defined for Taxonomy Term processor.')); + } +} diff --git a/sites/all/modules/feeds/plugins/FeedsUserProcessor.inc b/sites/all/modules/feeds/plugins/FeedsUserProcessor.inc new file mode 100644 index 0000000000000000000000000000000000000000..b067c4146562619f501af279c2240a40f5492f81 --- /dev/null +++ b/sites/all/modules/feeds/plugins/FeedsUserProcessor.inc @@ -0,0 +1,242 @@ +<?php + +/** + * @file + * FeedsUserProcessor class. + */ + +/** + * Feeds processor plugin. Create users from feed items. + */ +class FeedsUserProcessor extends FeedsProcessor { + /** + * Define entity type. + */ + public function entityType() { + return 'user'; + } + + /** + * Implements parent::entityInfo(). + */ + protected function entityInfo() { + $info = parent::entityInfo(); + $info['label plural'] = t('Users'); + return $info; + } + + /** + * Creates a new user account in memory and returns it. + */ + protected function newEntity(FeedsSource $source) { + $account = new stdClass(); + $account->uid = 0; + $account->roles = array_filter($this->config['roles']); + $account->status = $this->config['status']; + return $account; + } + + /** + * Loads an existing user. + */ + protected function entityLoad(FeedsSource $source, $uid) { + // Copy the password so that we can compare it again at save. + $user = user_load($uid); + $user->feeds_original_pass = $user->pass; + return $user; + } + + /** + * Validates a user account. + */ + protected function entityValidate($account) { + if (empty($account->name) || empty($account->mail) || !valid_email_address($account->mail)) { + throw new FeedsValidationException(t('User name missing or email not valid.')); + } + } + + /** + * Save a user account. + */ + protected function entitySave($account) { + if ($this->config['defuse_mail']) { + $account->mail = $account->mail . '_test'; + } + + $edit = (array) $account; + + // Remove pass from $edit if the password is unchanged. + if (isset($account->feeds_original_pass) && $account->pass == $account->feeds_original_pass) { + unset($edit['pass']); + } + + user_save($account, $edit); + if ($account->uid && !empty($account->openid)) { + $authmap = array( + 'uid' => $account->uid, + 'module' => 'openid', + 'authname' => $account->openid, + ); + if (SAVED_UPDATED != drupal_write_record('authmap', $authmap, array('uid', 'module'))) { + drupal_write_record('authmap', $authmap); + } + } + } + + /** + * Delete multiple user accounts. + */ + protected function entityDeleteMultiple($uids) { + foreach ($uids as $uid) { + user_delete($uid); + } + } + + /** + * Override parent::configDefaults(). + */ + public function configDefaults() { + return array( + 'roles' => array(), + 'status' => 1, + 'defuse_mail' => FALSE, + ) + parent::configDefaults(); + } + + /** + * Override parent::configForm(). + */ + public function configForm(&$form_state) { + $form = parent::configForm($form_state); + $form['status'] = array( + '#type' => 'radios', + '#title' => t('Status'), + '#description' => t('Select whether users should be imported active or blocked.'), + '#options' => array(0 => t('Blocked'), 1 => t('Active')), + '#default_value' => $this->config['status'], + ); + + $roles = user_roles(TRUE); + unset($roles[2]); + if (count($roles)) { + $form['roles'] = array( + '#type' => 'checkboxes', + '#title' => t('Additional roles'), + '#description' => t('Every user is assigned the "authenticated user" role. Select additional roles here.'), + '#default_value' => $this->config['roles'], + '#options' => $roles, + ); + } + // @todo Implement true updating. + $form['update_existing'] = array( + '#type' => 'checkbox', + '#title' => t('Replace existing users'), + '#description' => t('If an existing user is found for an imported user, replace it. Existing users will be determined using mappings that are a "unique target".'), + '#default_value' => $this->config['update_existing'], + ); + $form['defuse_mail'] = array( + '#type' => 'checkbox', + '#title' => t('Defuse e-mail addresses'), + '#description' => t('This appends _test to all imported e-mail addresses to ensure they cannot be used as recipients.'), + '#default_value' => $this->config['defuse_mail'], + ); + return $form; + } + + /** + * Override setTargetElement to operate on a target item that is a node. + */ + public function setTargetElement(FeedsSource $source, $target_user, $target_element, $value) { + switch ($target_element) { + case 'created': + $target_user->created = feeds_to_unixtime($value, REQUEST_TIME); + break; + case 'language': + $target_user->language = strtolower($value); + break; + default: + parent::setTargetElement($source, $target_user, $target_element, $value); + break; + } + } + + /** + * Return available mapping targets. + */ + public function getMappingTargets() { + $targets = parent::getMappingTargets(); + $targets += array( + 'name' => array( + 'name' => t('User name'), + 'description' => t('Name of the user.'), + 'optional_unique' => TRUE, + ), + 'mail' => array( + 'name' => t('Email address'), + 'description' => t('Email address of the user.'), + 'optional_unique' => TRUE, + ), + 'created' => array( + 'name' => t('Created date'), + 'description' => t('The created (e. g. joined) data of the user.'), + ), + 'pass' => array( + 'name' => t('Unencrypted Password'), + 'description' => t('The unencrypted user password.'), + ), + 'status' => array( + 'name' => t('Account status'), + 'description' => t('Whether a user is active or not. 1 stands for active, 0 for blocked.'), + ), + 'language' => array( + 'name' => t('User language'), + 'description' => t('Default language for the user.'), + ), + ); + if (module_exists('openid')) { + $targets['openid'] = array( + 'name' => t('OpenID identifier'), + 'description' => t('The OpenID identifier of the user. <strong>CAUTION:</strong> Use only for migration purposes, misconfiguration of the OpenID identifier can lead to severe security breaches like users gaining access to accounts other than their own.'), + 'optional_unique' => TRUE, + ); + } + + // Let other modules expose mapping targets. + self::loadMappers(); + $entity_type = $this->entityType(); + $bundle = $this->entityType(); + drupal_alter('feeds_processor_targets', $targets, $entity_type, $bundle); + + return $targets; + } + + /** + * Get id of an existing feed item term if available. + */ + protected function existingEntityId(FeedsSource $source, FeedsParserResult $result) { + if ($uid = parent::existingEntityId($source, $result)) { + return $uid; + } + + // Iterate through all unique targets and try to find a user for the + // target's value. + foreach ($this->uniqueTargets($source, $result) as $target => $value) { + switch ($target) { + case 'name': + $uid = db_query("SELECT uid FROM {users} WHERE name = :name", array(':name' => $value))->fetchField(); + break; + case 'mail': + $uid = db_query("SELECT uid FROM {users} WHERE mail = :mail", array(':mail' => $value))->fetchField(); + break; + case 'openid': + $uid = db_query("SELECT uid FROM {authmap} WHERE authname = :authname AND module = 'openid'", array(':authname' => $value))->fetchField(); + break; + } + if ($uid) { + // Return with the first nid found. + return $uid; + } + } + return 0; + } +} diff --git a/sites/all/modules/feeds/tests/common_syndication_parser.test b/sites/all/modules/feeds/tests/common_syndication_parser.test new file mode 100644 index 0000000000000000000000000000000000000000..d61baad4b285bb80fad47fa458a17dc5059029f6 --- /dev/null +++ b/sites/all/modules/feeds/tests/common_syndication_parser.test @@ -0,0 +1,95 @@ +<?php + +/** + * @file + * Tests for the common syndication parser. + */ + +/** + * Test cases for common syndication parser library. + * + * @todo Break out into Drupal independent test framework. + * @todo Could I use DrupalUnitTestCase here? + */ +class CommonSyndicationParserTestCase extends DrupalWebTestCase { + public static function getInfo() { + return array( + 'name' => 'Common Syndication Parser', + 'description' => 'Unit tests for Common Syndication Parser.', + 'group' => 'Feeds', + ); + } + + public function setUp() { + parent::setUp(array('feeds', 'feeds_ui', 'ctools', 'job_scheduler')); + feeds_include_library('common_syndication_parser.inc', 'common_syndication_parser'); + } + + /** + * Dispatch tests, only use one entry point method testX to save time. + */ + public function test() { + $this->_testRSS10(); + $this->_testRSS2(); + $this->_testAtomGeoRSS(); + } + + /** + * Test RSS 1.0. + */ + protected function _testRSS10() { + $string = $this->readFeed('magento.rss1'); + $feed = common_syndication_parser_parse($string); + $this->assertEqual($feed['title'], 'Magento Sites Network - A directory listing of Magento Commerce stores'); + $this->assertEqual($feed['items'][0]['title'], 'Gezondheidswebwinkel'); + $this->assertEqual($feed['items'][0]['url'], 'http://www.magentosites.net/store/2010/04/28/gezondheidswebwinkel/index.html'); + $this->assertEqual($feed['items'][1]['url'], 'http://www.magentosites.net/store/2010/04/26/mybobinocom/index.html'); + $this->assertEqual($feed['items'][1]['guid'], 'http://www.magentosites.net/node/3472'); + $this->assertEqual($feed['items'][2]['guid'], 'http://www.magentosites.net/node/3471'); + $this->assertEqual($feed['items'][2]['timestamp'], 1272285294); + } + + /** + * Test RSS 2. + */ + protected function _testRSS2() { + $string = $this->readFeed('developmentseed.rss2'); + $feed = common_syndication_parser_parse($string); + $this->assertEqual($feed['title'], 'Development Seed - Technological Solutions for Progressive Organizations'); + $this->assertEqual($feed['items'][0]['title'], 'Open Atrium Translation Workflow: Two Way Translation Updates'); + $this->assertEqual($feed['items'][1]['url'], 'http://developmentseed.org/blog/2009/oct/05/week-dc-tech-october-5th-edition'); + $this->assertEqual($feed['items'][1]['guid'], '973 at http://developmentseed.org'); + $this->assertEqual($feed['items'][2]['guid'], '972 at http://developmentseed.org'); + $this->assertEqual($feed['items'][2]['timestamp'], 1254493864); + } + + /** + * Test Geo RSS in Atom feed. + */ + protected function _testAtomGeoRSS() { + $string = $this->readFeed('earthquake-georss.atom'); + $feed = common_syndication_parser_parse($string); + $this->assertEqual($feed['title'], 'USGS M2.5+ Earthquakes'); + $this->assertEqual($feed['items'][0]['title'], 'M 2.6, Central Alaska'); + $this->assertEqual($feed['items'][1]['url'], 'http://earthquake.usgs.gov/earthquakes/recenteqsww/Quakes/us2010axbz.php'); + $this->assertEqual($feed['items'][1]['guid'], 'urn:earthquake-usgs-gov:us:2010axbz'); + $this->assertEqual($feed['items'][2]['guid'], 'urn:earthquake-usgs-gov:us:2010axbr'); + $this->assertEqual($feed['items'][2]['geolocations'][0]['name'], '-53.1979 -118.0676'); + $this->assertEqual($feed['items'][2]['geolocations'][0]['lat'], '-53.1979'); + $this->assertEqual($feed['items'][2]['geolocations'][0]['lon'], '-118.0676'); + $this->assertEqual($feed['items'][3]['geolocations'][0]['name'], '-43.4371 172.5902'); + $this->assertEqual($feed['items'][3]['geolocations'][0]['lat'], '-43.4371'); + $this->assertEqual($feed['items'][3]['geolocations'][0]['lon'], '172.5902'); + } + + /** + * Helper to read a feed. + */ + protected function readFeed($filename) { + $feed = dirname(__FILE__) . '/feeds/' . $filename; + $handle = fopen($feed, 'r'); + $string = fread($handle, filesize($feed)); + fclose($handle); + return $string; + } +} diff --git a/sites/all/modules/feeds/tests/feeds.test b/sites/all/modules/feeds/tests/feeds.test new file mode 100644 index 0000000000000000000000000000000000000000..4bcb20c16b6da7eb59dafa4b6e033139987ae6d7 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds.test @@ -0,0 +1,674 @@ +<?php + +/** + * @file + * Common functionality for all Feeds tests. + */ + +/** + * Test basic Data API functionality. + */ +class FeedsWebTestCase extends DrupalWebTestCase { + protected $profile = 'testing'; + + public function setUp() { + $args = func_get_args(); + + // Build the list of required modules which can be altered by passing in an + // array of module names to setUp(). + if (isset($args[0])) { + if (is_array($args[0])) { + $modules = $args[0]; + } + else { + $modules = $args; + } + } + else { + $modules = array(); + } + + $modules[] = 'taxonomy'; + $modules[] = 'image'; + $modules[] = 'file'; + $modules[] = 'field'; + $modules[] = 'field_ui'; + $modules[] = 'feeds'; + $modules[] = 'feeds_ui'; + $modules[] = 'feeds_tests'; + $modules[] = 'ctools'; + $modules[] = 'job_scheduler'; + $modules = array_unique($modules); + parent::setUp($modules); + + // Add text formats Directly. + $filtered_html_format = array( + 'format' => 'filtered_html', + 'name' => 'Filtered HTML', + 'weight' => 0, + 'filters' => array( + // URL filter. + 'filter_url' => array( + 'weight' => 0, + 'status' => 1, + ), + // HTML filter. + 'filter_html' => array( + 'weight' => 1, + 'status' => 1, + ), + // Line break filter. + 'filter_autop' => array( + 'weight' => 2, + 'status' => 1, + ), + // HTML corrector filter. + 'filter_htmlcorrector' => array( + 'weight' => 10, + 'status' => 1, + ), + ), + ); + $filtered_html_format = (object) $filtered_html_format; + filter_format_save($filtered_html_format); + + // Build the list of required administration permissions. Additional + // permissions can be passed as an array into setUp()'s second parameter. + if (isset($args[1]) && is_array($args[1])) { + $permissions = $args[1]; + } + else { + $permissions = array(); + } + + $permissions[] = 'access content'; + $permissions[] = 'administer site configuration'; + $permissions[] = 'administer content types'; + $permissions[] = 'administer nodes'; + $permissions[] = 'bypass node access'; + $permissions[] = 'administer taxonomy'; + $permissions[] = 'administer users'; + $permissions[] = 'administer feeds'; + + // Create an admin user and log in. + $this->admin_user = $this->drupalCreateUser($permissions); + $this->drupalLogin($this->admin_user); + + $types = array( + array( + 'type' => 'page', + 'name' => 'Basic page', + 'node_options[status]' => 1, + 'node_options[promote]' => 0, + ), + array( + 'type' => 'article', + 'name' => 'Article', + 'node_options[status]' => 1, + 'node_options[promote]' => 1, + ), + ); + foreach ($types as $type) { + $this->drupalPost('admin/structure/types/add', $type, 'Save content type'); + $this->assertText("The content type " . $type['name'] . " has been added."); + } + } + + /** + * Absolute path to Drupal root. + */ + public function absolute() { + return realpath(getcwd()); + } + + /** + * Get the absolute directory path of the feeds module. + */ + public function absolutePath() { + return $this->absolute() . '/' . drupal_get_path('module', 'feeds'); + } + + /** + * Generate an OPML test feed. + * + * The purpose of this function is to create a dynamic OPML feed that points + * to feeds included in this test. + */ + public function generateOPML() { + $path = $GLOBALS['base_url'] . '/' . drupal_get_path('module', 'feeds') . '/tests/feeds/'; + + $output = +'<?xml version="1.0" encoding="utf-8"?> +<opml version="1.1"> +<head> + <title>Feeds test OPML</title> + <dateCreated>Fri, 16 Oct 2009 02:53:17 GMT</dateCreated> + <ownerName></ownerName> +</head> +<body> + <outline text="Feeds test group" > + <outline title="Development Seed - Technological Solutions for Progressive Organizations" text="" xmlUrl="' . $path . 'developmentseed.rss2" type="rss" /> + <outline title="Magyar Nemzet Online - H\'rek" text="" xmlUrl="' . $path . 'feed_without_guid.rss2" type="rss" /> + <outline title="Drupal planet" text="" type="rss" xmlUrl="' . $path . 'drupalplanet.rss2" /> + </outline> +</body> +</opml>'; + + // UTF 8 encode output string and write it to disk + $output = utf8_encode($output); + $filename = file_default_scheme() . '://test-opml-' . $this->randomName() . '.opml'; + + $filename = file_unmanaged_save_data($output, $filename); + return $filename; + } + + /** + * Create an importer configuration. + * + * @param $name + * The natural name of the feed. + * @param $id + * The persistent id of the feed. + * @param $edit + * Optional array that defines the basic settings for the feed in a format + * that can be posted to the feed's basic settings form. + */ + public function createImporterConfiguration($name = 'Syndication', $id = 'syndication') { + // Create new feed configuration. + $this->drupalGet('admin/structure/feeds'); + $this->clickLink('Add importer'); + $edit = array( + 'name' => $name, + 'id' => $id, + ); + $this->drupalPost('admin/structure/feeds/create', $edit, 'Create'); + + // Assert message and presence of default plugins. + $this->assertText('Your configuration has been created with default settings.'); + $this->assertPlugins($id, 'FeedsHTTPFetcher', 'FeedsSyndicationParser', 'FeedsNodeProcessor'); + // Per default attach to page content type. + $this->setSettings($id, NULL, array('content_type' => 'page')); + } + + /** + * Choose a plugin for a importer configuration and assert it. + * + * @param $id + * The importer configuration's id. + * @param $plugin_key + * The key string of the plugin to choose (one of the keys defined in + * feeds_feeds_plugins()). + */ + public function setPlugin($id, $plugin_key) { + if ($type = FeedsPlugin::typeOf($plugin_key)) { + $edit = array( + 'plugin_key' => $plugin_key, + ); + $this->drupalPost("admin/structure/feeds/$id/$type", $edit, 'Save'); + + // Assert actual configuration. + $config = unserialize(db_query("SELECT config FROM {feeds_importer} WHERE id = :id", array(':id' => $id))->fetchField()); + $this->assertEqual($config[$type]['plugin_key'], $plugin_key, 'Verified correct ' . $type . ' (' . $plugin_key . ').'); + } + } + + /** + * Set importer or plugin settings. + * + * @param $id + * The importer configuration's id. + * @param $plugin + * The plugin (class) name, or NULL to set importer's settings + * @param $settings + * The settings to set. + */ + public function setSettings($id, $plugin, $settings) { + $this->drupalPost('admin/structure/feeds/' . $id . '/settings/' . $plugin, $settings, 'Save'); + $this->assertText('Your changes have been saved.'); + } + + /** + * Create a test feed node. Test user has to have sufficient permissions: + * + * * create [type] content + * * use feeds + * + * Assumes that page content type has been configured with + * createImporterConfiguration() as a feed content type. + * + * @return + * The node id of the node created. + */ + public function createFeedNode($id = 'syndication', $feed_url = NULL, $title = '', $content_type = NULL) { + if (empty($feed_url)) { + $feed_url = $GLOBALS['base_url'] . '/' . drupal_get_path('module', 'feeds') . '/tests/feeds/developmentseed.rss2'; + } + + // If content type not given, retrieve it. + if (!$content_type) { + $result= db_select('feeds_importer', 'f') + ->condition('f.id', $id, '=') + ->fields('f', array('config')) + ->execute(); + $config = unserialize($result->fetchField()); + $content_type = $config['content_type']; + $this->assertFalse(empty($content_type), 'Valid content type found: ' . $content_type); + } + + // Create a feed node. + $edit = array( + 'title' => $title, + 'feeds[FeedsHTTPFetcher][source]' => $feed_url, + ); + $this->drupalPost('node/add/' . str_replace('_', '-', $content_type), $edit, 'Save'); + $this->assertText('has been created.'); + + // Get the node id from URL. + $nid = $this->getNid($this->getUrl()); + + // Check whether feed got recorded in feeds_source table. + $query = db_select('feeds_source', 's') + ->condition('s.id', $id, '=') + ->condition('s.feed_nid', $nid, '='); + $query->addExpression("COUNT(*)"); + $result = $query->execute()->fetchField(); + $this->assertEqual(1, $result); + + $source = db_select('feeds_source', 's') + ->condition('s.id', $id, '=') + ->condition('s.feed_nid', $nid, '=') + ->fields('s', array('config')) + ->execute()->fetchObject(); + $config = unserialize($source->config); + $this->assertEqual($config['FeedsHTTPFetcher']['source'], $feed_url, t('URL in DB correct.')); + return $nid; + } + + /** + * Edit the configuration of a feed node to test update behavior. + * + * @param $nid + * The nid to edit. + * @param $feed_url + * The new (absolute) feed URL to use. + * @param $title + * Optional parameter to change title of feed node. + */ + public function editFeedNode($nid, $feed_url, $title = '') { + $edit = array( + 'title' => $title, + 'feeds[FeedsHTTPFetcher][source]' => $feed_url, + ); + // Check that the update was saved. + $this->drupalPost('node/' . $nid . '/edit', $edit, 'Save'); + $this->assertText('has been updated.'); + + // Check that the URL was updated in the feeds_source table. + $source = db_query("SELECT * FROM {feeds_source} WHERE feed_nid = :nid", array(':nid' => $nid))->fetchObject(); + $config = unserialize($source->config); + $this->assertEqual($config['FeedsHTTPFetcher']['source'], $feed_url, t('URL in DB correct.')); + } + + /** + * Batch create a variable amount of feed nodes. All will have the + * same URL configured. + * + * @return + * An array of node ids of the nodes created. + */ + public function createFeedNodes($id = 'syndication', $num = 20, $content_type = NULL) { + $nids = array(); + for ($i = 0; $i < $num; $i++) { + $nids[] = $this->createFeedNode($id, NULL, $this->randomName(), $content_type); + } + return $nids; + } + + /** + * Import a URL through the import form. Assumes FeedsHTTPFetcher in place. + */ + public function importURL($id, $feed_url = NULL) { + if (empty($feed_url)) { + $feed_url = $GLOBALS['base_url'] . '/' . drupal_get_path('module', 'feeds') . '/tests/feeds/developmentseed.rss2'; + } + $edit = array( + 'feeds[FeedsHTTPFetcher][source]' => $feed_url, + ); + $nid = $this->drupalPost('import/' . $id, $edit, 'Import'); + + // Check whether feed got recorded in feeds_source table. + $this->assertEqual(1, db_query("SELECT COUNT(*) FROM {feeds_source} WHERE id = :id AND feed_nid = 0", array(':id' => $id))->fetchField()); + $source = db_query("SELECT * FROM {feeds_source} WHERE id = :id AND feed_nid = 0", array(':id' => $id))->fetchObject(); + $config = unserialize($source->config); + $this->assertEqual($config['FeedsHTTPFetcher']['source'], $feed_url, t('URL in DB correct.')); + + // Check whether feed got properly added to scheduler. + $this->assertEqual(1, db_query("SELECT COUNT(*) FROM {job_schedule} WHERE type = :id AND id = 0 AND name = 'feeds_source_import' AND last <> 0 AND scheduled = 0", array(':id' => $id))->fetchField()); + // There must be only one entry for callback 'expire' - no matter what the feed_nid is. + $this->assertEqual(0, db_query("SELECT COUNT(*) FROM {job_schedule} WHERE type = :id AND name = 'feeds_importer_expire' AND last <> 0 AND scheduled = 0", array(':id' => $id))->fetchField()); + } + + /** + * Import a file through the import form. Assumes FeedsFileFetcher in place. + */ + public function importFile($id, $file) { + + $this->assertTrue(file_exists($file), 'Source file exists'); + $edit = array( + 'files[feeds]' => $file, + ); + $this->drupalPost('import/' . $id, $edit, 'Import'); + } + + /** + * Assert a feeds configuration's plugins. + * + * @deprecated: + * Use setPlugin() instead. + * + * @todo Refactor users of assertPlugin() and make them use setPugin() instead. + */ + public function assertPlugins($id, $fetcher, $parser, $processor) { + // Assert actual configuration. + $config = unserialize(db_query("SELECT config FROM {feeds_importer} WHERE id = :id", array(':id' => $id))->fetchField()); + + $this->assertEqual($config['fetcher']['plugin_key'], $fetcher, 'Correct fetcher'); + $this->assertEqual($config['parser']['plugin_key'], $parser, 'Correct parser'); + $this->assertEqual($config['processor']['plugin_key'], $processor, 'Correct processor'); + } + + /** + * Adds mappings to a given configuration. + * + * @param string $id + * ID of the importer. + * @param array $mappings + * An array of mapping arrays. Each mapping array must have a source and + * an target key and can have a unique key. + * @param bool $test_mappings + * (optional) TRUE to automatically test mapping configs. Defaults to TRUE. + */ + public function addMappings($id, $mappings, $test_mappings = TRUE) { + + $path = "admin/structure/feeds/$id/mapping"; + + // Iterate through all mappings and add the mapping via the form. + foreach ($mappings as $i => $mapping) { + + if ($test_mappings) { + $current_mapping_key = $this->mappingExists($id, $i, $mapping['source'], $mapping['target']); + $this->assertEqual($current_mapping_key, -1, 'Mapping does not exist before addition.'); + } + + // Get unique flag and unset it. Otherwise, drupalPost will complain that + // Split up config and mapping. + $config = $mapping; + unset($config['source'], $config['target']); + $mapping = array('source' => $mapping['source'], 'target' => $mapping['target']); + + // Add mapping. + $this->drupalPost($path, $mapping, t('Add')); + + // If there are other configuration options, set them. + if ($config) { + $this->drupalPostAJAX(NULL, array(), 'mapping_settings_edit_' . $i); + + // Set some settings. + $edit = array(); + foreach ($config as $key => $value) { + $edit["config[$i][settings][$key]"] = $value; + } + $this->drupalPostAJAX(NULL, $edit, 'mapping_settings_update_' . $i); + $this->drupalPost(NULL, array(), t('Save')); + } + + if ($test_mappings) { + $current_mapping_key = $this->mappingExists($id, $i, $mapping['source'], $mapping['target']); + $this->assertTrue($current_mapping_key >= 0, 'Mapping exists after addition.'); + } + } + } + + /** + * Remove mappings from a given configuration. + * + * This function mimicks the Javascript behavior in feeds_ui.js + * + * @param array $mappings + * An array of mapping arrays. Each mapping array must have a source and + * a target key and can have a unique key. + * @param bool $test_mappings + * (optional) TRUE to automatically test mapping configs. Defaults to TRUE. + */ + public function removeMappings($id, $mappings, $test_mappings = TRUE) { + $path = "admin/structure/feeds/$id/mapping"; + + $current_mappings = $this->getCurrentMappings($id); + + // Iterate through all mappings and remove via the form. + foreach ($mappings as $i => $mapping) { + + if ($test_mappings) { + $current_mapping_key = $this->mappingExists($id, $i, $mapping['source'], $mapping['target']); + $this->assertEqual($current_mapping_key, $i, 'Mapping exists before removal.'); + } + + $remove_mapping = array("remove_flags[$i]" => 1); + + $this->drupalPost($path, $remove_mapping, t('Save')); + + $this->assertText('Your changes have been saved.'); + + if ($test_mappings) { + $current_mapping_key = $this->mappingExists($id, $i, $mapping['source'], $mapping['target']); + $this->assertEqual($current_mapping_key, -1, 'Mapping does not exist after removal.'); + } + } + } + + /** + * Gets an array of current mappings from the feeds_importer config. + * + * @param string $id + * ID of the importer. + * + * @return bool|array + * FALSE if the importer has no mappings, or an an array of mappings. + */ + public function getCurrentMappings($id) { + $config = db_query("SELECT config FROM {feeds_importer} WHERE id = :id", array(':id' => $id))->fetchField(); + + $config = unserialize($config); + + // We are very specific here. 'mappings' can either be an array or not + // exist. + if (array_key_exists('mappings', $config['processor']['config'])) { + $this->assertTrue(is_array($config['processor']['config']['mappings']), 'Mappings is an array.'); + + return $config['processor']['config']['mappings']; + } + + return FALSE; + } + + /** + * Determines if a mapping exists for a given importer. + * + * @param string $id + * ID of the importer. + * @param integer $i + * The key of the mapping. + * @param string $source + * The source field. + * @param string $target + * The target field. + * + * @return integer + * -1 if the mapping doesn't exist, the key of the mapping otherwise. + */ + public function mappingExists($id, $i, $source, $target) { + + $current_mappings = $this->getCurrentMappings($id); + + if ($current_mappings) { + foreach ($current_mappings as $key => $mapping) { + if ($mapping['source'] == $source && $mapping['target'] == $target && $key == $i) { + return $key; + } + } + } + + return -1; + } + + /** + * Helper function, retrieves node id from a URL. + */ + public function getNid($url) { + $matches = array(); + preg_match('/node\/(\d+?)$/', $url, $matches); + $nid = $matches[1]; + + // Test for actual integerness. + $this->assertTrue($nid === (string) (int) $nid, 'Node id is an integer.'); + + return $nid; + } + + /** + * Copies a directory. + */ + public function copyDir($source, $dest) { + $result = file_prepare_directory($dest, FILE_CREATE_DIRECTORY | FILE_MODIFY_PERMISSIONS); + foreach (@scandir($source) as $file) { + if (is_file("$source/$file")) { + $file = file_unmanaged_copy("$source/$file", "$dest/$file"); + } + } + } + + /** + * Download and extract SimplePIE. + * + * Sets the 'feeds_simplepie_library_dir' variable to the directory where + * SimplePie is downloaded. + */ + function downloadExtractSimplePie($version) { + $url = "http://simplepie.org/downloads/simplepie_$version.mini.php"; + $filename = 'simplepie.mini.php'; + + // Avoid downloading the file dozens of times + $library_dir = DRUPAL_ROOT . '/' . $this->originalFileDirectory . '/simpletest/feeds'; + $simplepie_library_dir = $library_dir . '/simplepie'; + + if (!file_exists($library_dir)) { + drupal_mkdir($library_dir); + } + + if (!file_exists($simplepie_library_dir)) { + drupal_mkdir($simplepie_library_dir); + } + + // Local file name. + $local_file = $simplepie_library_dir . '/' . $filename; + + // Begin single threaded code. + if (function_exists('sem_get')) { + $semaphore = sem_get(ftok(__FILE__, 1)); + sem_acquire($semaphore); + } + + // Download and extact the archive, but only in one thread. + if (!file_exists($local_file)) { + $local_file = system_retrieve_file($url, $local_file, FALSE, FILE_EXISTS_REPLACE); + } + + if (function_exists('sem_get')) { + sem_release($semaphore); + } + // End single threaded code. + + // Verify that files were successfully extracted. + $this->assertTrue(file_exists($local_file), t('@file found.', array('@file' => $local_file))); + + // Set the simpletest library directory. + variable_set('feeds_library_dir', $library_dir); + } +} + +/** + * Provides a wrapper for DrupalUnitTestCase for Feeds unit testing. + */ +class FeedsUnitTestHelper extends DrupalUnitTestCase { + public function setUp() { + parent::setUp(); + + // Manually include the feeds module. + // @todo Allow an array of modules from the child class. + drupal_load('module', 'feeds'); + } +} + +class FeedsUnitTestCase extends FeedsUnitTestHelper { + public static function getInfo() { + return array( + 'name' => 'Unit tests', + 'description' => 'Test basic low-level Feeds module functionality.', + 'group' => 'Feeds', + ); + } + + /** + * Test valid absolute urls. + * + * @see ValidUrlTestCase + * + * @todo Remove when http://drupal.org/node/1191252 is fixed. + */ + function testFeedsValidURL() { + $url_schemes = array('http', 'https', 'ftp', 'feed', 'webcal'); + $valid_absolute_urls = array( + 'example.com', + 'www.example.com', + 'ex-ample.com', + '3xampl3.com', + 'example.com/paren(the)sis', + 'example.com/index.html#pagetop', + 'example.com:8080', + 'subdomain.example.com', + 'example.com/index.php?q=node', + 'example.com/index.php?q=node¶m=false', + 'user@www.example.com', + 'user:pass@www.example.com:8080/login.php?do=login&style=%23#pagetop', + '127.0.0.1', + 'example.org?', + 'john%20doe:secret:foo@example.org/', + 'example.org/~,$\'*;', + 'caf%C3%A9.example.org', + '[FEDC:BA98:7654:3210:FEDC:BA98:7654:3210]:80/index.html', + 'graph.asfdasdfasdf.com/blarg/feed?access_token=133283760145143|tGew8jbxi1ctfVlYh35CPYij1eE', + ); + + foreach ($url_schemes as $scheme) { + foreach ($valid_absolute_urls as $url) { + $test_url = $scheme . '://' . $url; + $valid_url = feeds_valid_url($test_url, TRUE); + $this->assertTrue($valid_url, t('@url is a valid url.', array('@url' => $test_url))); + } + } + + $invalid_ablosule_urls = array( + '', + 'ex!ample.com', + 'ex%ample.com', + ); + + foreach ($url_schemes as $scheme) { + foreach ($invalid_ablosule_urls as $url) { + $test_url = $scheme . '://' . $url; + $valid_url = feeds_valid_url($test_url, TRUE); + $this->assertFalse($valid_url, t('@url is NOT a valid url.', array('@url' => $test_url))); + } + } + } +} diff --git a/sites/all/modules/feeds/tests/feeds/assets/attersee.jpeg b/sites/all/modules/feeds/tests/feeds/assets/attersee.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..1f48fe7d50683ac94a2f7c5bf79388f582f2df0f Binary files /dev/null and b/sites/all/modules/feeds/tests/feeds/assets/attersee.jpeg differ diff --git a/sites/all/modules/feeds/tests/feeds/assets/foosball.jpeg b/sites/all/modules/feeds/tests/feeds/assets/foosball.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..fbbc660167db1e90f628fca1069c747afef68e84 Binary files /dev/null and b/sites/all/modules/feeds/tests/feeds/assets/foosball.jpeg differ diff --git a/sites/all/modules/feeds/tests/feeds/assets/hstreet.jpeg b/sites/all/modules/feeds/tests/feeds/assets/hstreet.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..65591b21bdd86e4b30ecee24789965499fe31835 Binary files /dev/null and b/sites/all/modules/feeds/tests/feeds/assets/hstreet.jpeg differ diff --git a/sites/all/modules/feeds/tests/feeds/assets/la fayette.jpeg b/sites/all/modules/feeds/tests/feeds/assets/la fayette.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..aacd1728c77b9ec8ba268fef0205d4bb126042ef Binary files /dev/null and b/sites/all/modules/feeds/tests/feeds/assets/la fayette.jpeg differ diff --git a/sites/all/modules/feeds/tests/feeds/assets/tubing.jpeg b/sites/all/modules/feeds/tests/feeds/assets/tubing.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..82afe0b58ed2c0d41aeef59dbd83af79f0bb20a3 Binary files /dev/null and b/sites/all/modules/feeds/tests/feeds/assets/tubing.jpeg differ diff --git a/sites/all/modules/feeds/tests/feeds/batch/nodes1.csv b/sites/all/modules/feeds/tests/feeds/batch/nodes1.csv new file mode 100644 index 0000000000000000000000000000000000000000..45f343a23d0fd986d0440e5dbc14afc707d64c47 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds/batch/nodes1.csv @@ -0,0 +1,10 @@ +Title,Body,published,GUID +"Ut wisi enim ad minim veniam", "Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat.",205200720,2 +"Duis autem vel eum iriure dolor", "Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi.",428112720,3 +"Nam liber tempor", "Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum.",1151766000,1 +Typi non habent"", "Typi non habent claritatem insitam; est usus legentis in iis qui facit eorum claritatem.",1256326995,4 +"Lorem ipsum","Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat.",1251936720,1 +"Investigationes demonstraverunt", "Investigationes demonstraverunt lectores legere me lius quod ii legunt saepius.",946702800,5 +"Claritas est etiam", "Claritas est etiam processus dynamicus, qui sequitur mutationem consuetudium lectorum.",438112720,6 +"Mirum est notare", "Mirum est notare quam littera gothica, quam nunc putamus parum claram, anteposuerit litterarum formas humanitatis per seacula quarta decima et quinta decima.",1151066000,7 +"Eodem modo typi", "Eodem modo typi, qui nunc nobis videntur parum clari, fiant sollemnes in futurum.",1201936720,8 diff --git a/sites/all/modules/feeds/tests/feeds/batch/nodes2.csv b/sites/all/modules/feeds/tests/feeds/batch/nodes2.csv new file mode 100644 index 0000000000000000000000000000000000000000..4442523e84a111303de6250245604435cca28633 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds/batch/nodes2.csv @@ -0,0 +1,3 @@ +Title,Body,published,GUID +"Duis autem vel eum iriure dolor", "Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi.",428112720,3 +"Nam liber tempor", "Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum.",1151766000,1 diff --git a/sites/all/modules/feeds/tests/feeds/batch/nodes3.csv b/sites/all/modules/feeds/tests/feeds/batch/nodes3.csv new file mode 100644 index 0000000000000000000000000000000000000000..3f1fd6b42160ad8d51be4bcedd2cdb9a0401c170 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds/batch/nodes3.csv @@ -0,0 +1,4 @@ +Title,Body,published,GUID +"Typi non habent", "Typi non habent claritatem insitam; est usus legentis in iis qui facit eorum claritatem.",1256326995,4 +"Lorem ipsum","Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat.",1251936720,1 +"Investigationes demonstraverunt", "Investigationes demonstraverunt lectores legere me lius quod ii legunt saepius.",946702800,5 diff --git a/sites/all/modules/feeds/tests/feeds/batch/nodes4.csv b/sites/all/modules/feeds/tests/feeds/batch/nodes4.csv new file mode 100644 index 0000000000000000000000000000000000000000..9d7352b15c7e06e14113ddf62a5fb6c13c1c10e1 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds/batch/nodes4.csv @@ -0,0 +1,3 @@ +Title,Body,published,GUID +"Investigationes demonstraverunt", "Investigationes demonstraverunt lectores legere me lius quod ii legunt saepius.",946702800,5 +"Claritas est etiam", "Claritas est etiam processus dynamicus, qui sequitur mutationem consuetudium lectorum.",438112720,6 diff --git a/sites/all/modules/feeds/tests/feeds/batch/nodes5.csv b/sites/all/modules/feeds/tests/feeds/batch/nodes5.csv new file mode 100644 index 0000000000000000000000000000000000000000..dadc6973b8366dcf2a4cbaa2e4ddcbce934b6fa8 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds/batch/nodes5.csv @@ -0,0 +1,3 @@ +Title,Body,published,GUID +"Mirum est notare", "Mirum est notare quam littera gothica, quam nunc putamus parum claram, anteposuerit litterarum formas humanitatis per seacula quarta decima et quinta decima.",1151066000,7 +"Eodem modo typi", "Eodem modo typi, qui nunc nobis videntur parum clari, fiant sollemnes in futurum.",1201936720,8 \ No newline at end of file diff --git a/sites/all/modules/feeds/tests/feeds/content.csv b/sites/all/modules/feeds/tests/feeds/content.csv new file mode 100644 index 0000000000000000000000000000000000000000..1e68bdf865ddc8e4900ead66e83b46bce1938a6f --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds/content.csv @@ -0,0 +1,3 @@ +"guid","title","created","alpha","beta","gamma","delta","body" +1,"Lorem ipsum",1251936720,"Lorem",42,"4.2",3.14159265,"Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat." +2,"Ut wisi enim ad minim veniam",1251932360,"Ut wisi",32,"1.2",5.62951413,"Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat." diff --git a/sites/all/modules/feeds/tests/feeds/developmentseed.rss2 b/sites/all/modules/feeds/tests/feeds/developmentseed.rss2 new file mode 100644 index 0000000000000000000000000000000000000000..90f5deafac4f40cc15c8a75750f7bc4ccdc9aa98 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds/developmentseed.rss2 @@ -0,0 +1,299 @@ +<?xml version="1.0" encoding="utf-8" ?><rss version="2.0" xml:base="http://developmentseed.org/blog/all" xmlns:dc="http://purl.org/dc/elements/1.1/"> + <channel> + <title>Development Seed - Technological Solutions for Progressive Organizations</title> + <link>http://developmentseed.org/blog/all</link> + <description></description> + <language>en</language> + <item> + <title>Open Atrium Translation Workflow: Two Way Translation Updates</title> + <link>http://developmentseed.org/blog/2009/oct/06/open-atrium-translation-workflow-two-way-updating</link> + <description><div class="field field-type-text field-field-subtitle"> + <div class="field-items"> + <div class="field-item odd"> + <p>A new translation process for Open Atrium & integration with Localize Drupal</p> </div> + </div> +</div> +<div class='node-body'><p>The <a href="http://openatrium.com/">Open Atrium</a> <a href="http://developmentseed.org/blog/2009/jul/16/open-atrium-solving-translation-puzzle">translation infrastructure</a> (and Drupal translations in general) are progressing quickly. For Open Atrium to be well translated we first need Drupal's modules to be translated, so I am splitting efforts at the moment between helping with <a href="http://localize.drupal.org">Localize Drupal</a> and improving <a href="https://translate.openatrium.com">Open Atrium Translate</a>. Already, it is much easier to automatically download your language, get updates from a translation server, protect locally translated strings, and scale the translation system so that translation servers can talk to each other.</p> + +<h1>Automatically download your language</h1> + +<p><img src="http://farm3.static.flickr.com/2496/3984689117_57559c74eb.jpg" alt="Magical translation install" /></p> + +<p>For more than a month now you have been able to install Open Atrium, select one of its 20+ languages, and have the translation automatically downloaded and installed on your site. While this has been working for awhile now, we are still refining the process. One change we've already made is that now translations are downloaded in multiple smaller packages, rather than a single large one.</p> + +<p>Once your translation is installed, you can use tools like the <a href="http://drupal.org/project/l10n_client">Localization client</a>, which comes bundled in the Open Atrium install, to translate page strings and then optionally contribute them back to the localization server, automatically. This flow of translations goes both ways, so your site gets the latest updates from the server just as you can send your latest updates to the server.</p> + +<h1>Two way translation updates</h1> + +<p><em>But what happens with my locally translated strings, which I like more than the ones that come out of the box, when I update from the server?</em></p> + +<p><img src="http://farm3.static.flickr.com/2442/3984689343_e9b7c32718.jpg" alt="Two ways translation updates" /></p> + +<p>In a word, nothing. There has been a major improvement on this front. Now your translations are tracked and won't be overwritten by someone else's translations when you update, unless you choose for them to be. This means that you can contribute your translations, benefit from others contributing theirs, and make the world a better (translated) place, without loosing any custom work that you want. Let the translations flow!</p></div></description> + <comments>http://developmentseed.org/blog/2009/oct/06/open-atrium-translation-workflow-two-way-updating#comments</comments> + <category domain="http://developmentseed.org/tags/drupal">Drupal</category> + <category domain="http://developmentseed.org/tags/localization">localization</category> + <category domain="http://developmentseed.org/tags/localization-client">localization client</category> + <category domain="http://developmentseed.org/tags/localization-server">localization server</category> + <category domain="http://developmentseed.org/tags/open-atrium">open atrium</category> + <category domain="http://developmentseed.org/tags/translation">translation</category> + <category domain="http://developmentseed.org/tags/translation-server">translation server</category> + <category domain="http://developmentseed.org/channel/drupal-planet">Drupal planet</category> + <pubDate>Tue, 06 Oct 2009 15:21:48 +0000</pubDate> + <dc:creator>Development Seed</dc:creator> + <guid isPermaLink="false">974 at http://developmentseed.org</guid> + </item> + <item> + <title>Week in DC Tech: October 5th Edition</title> + <link>http://developmentseed.org/blog/2009/oct/05/week-dc-tech-october-5th-edition</link> + <description><div class="field field-type-text field-field-subtitle"> + <div class="field-items"> + <div class="field-item odd"> + <p>Drupal, PHP, and Mapping This Week in Washington, DC</p> </div> + </div> +</div> +<div class='node-body'><p><img src="http://developmentseed.org/sites/developmentseed.org/files/dctech2_0_0.png" alt="Week in DC Tech" /></p> + +<p>There are some great technology events happening this week in Washington, DC, so if you're looking to talk code, help map the city, or just hear about some neat projects and ideas, you're in luck. Below are the events that caught our eye, and you can find a full list of technology events happening this week at <a href="http://www.dctechevents.com/">DC Tech Events</a>. Have a great week!</p> + +<h1>Wednesday, October 7</h1> + +<p>6:30 pm</p> + +<p><a href="http://drupal.meetup.com/21/calendar/11332695/"><strong>NOVA Drupal Meetup</strong></a>: Are you a Drupal developer, use a Drupal site, or just want to learn more about the open source content management system? Come out for this meetup to meet other Drupal fans and hear how people are using the CMS.</p> + +<p>6:30 pm</p> + +<p><a href="http://groups.google.com/group/washington-dcphp-group/browse_thread/thread/716d4a625287fef5?hl=en"><strong>DC PHP Beverage Subgroup</strong></a>: If you're looking to talk code with PHP developers who share your interest, come out for this casual meetup to chat over beers.</p> + +<p>7:00 pm</p> + +<p><a href="http://hacdc.org/"><strong>Mapping DC Meeting</strong></a>: Come out for this meetup if you want to help create high quality, free maps of Washington, DC. The <a href="http://wiki.openstreetmap.org/wiki/MappingDC">Mapping DC</a> group is doing this, starting with creating a detailed map of the National Zoo.</p></div></description> + <comments>http://developmentseed.org/blog/2009/oct/05/week-dc-tech-october-5th-edition#comments</comments> + <category domain="http://developmentseed.org/tags/washington-dc">Washington DC</category> + <pubDate>Mon, 05 Oct 2009 15:27:40 +0000</pubDate> + <dc:creator>Development Seed</dc:creator> + <guid isPermaLink="false">973 at http://developmentseed.org</guid> + </item> + <item> + <title>Mapping Innovation at the World Bank with Open Atrium</title> + <link>http://developmentseed.org/blog/2009/oct/02/mapping-innovation-world-bank-open-atrium</link> + <description><div class="field field-type-text field-field-subtitle"> + <div class="field-items"> + <div class="field-item odd"> + <p>Using map and faceted search features to improve collaboration</p> </div> + </div> +</div> +<div class='node-body'><p><a href="http://openatrium.com/">Open Atrium</a> is being used as a base platform for collaboration at the World Bank because of its feature flexibility. Last week the World Bank launched a new Open Atrium site called "Innovate," which is being used to support an organization-wide initiative to better share information about successful projects and approaches to solving problems.</p> + +<p>The core of the site is built around helping World Bank staff discover relevant "innovations" happening around the world and providing a space to discuss them with colleagues in topical discussion groups. To facilitate this workflow we built a custom map-based browser feature that combines custom maps with faceted search, letting users quickly find interesting content. The screenshots below from a staging site with a partial database show what this feature looks like.</p> + +<p><img src="http://farm4.static.flickr.com/3419/3974644312_c992e1afe8.jpg" alt="The map-based browser feature makes custom maps with faceted search" /></p> + +<p>As users apply new facets to their searches, the map results update to reveal global coverage for innovations that meet the search criteria.</p> + +<p><img src="http://farm3.static.flickr.com/2600/3974644162_a44cc3a89a.jpg" alt="Add new facets to the search to further customize the map" /></p></div></description> + <comments>http://developmentseed.org/blog/2009/oct/02/mapping-innovation-world-bank-open-atrium#comments</comments> + <category domain="http://developmentseed.org/tags/custom-mapping">custom mapping</category> + <category domain="http://developmentseed.org/tags/drupal">Drupal</category> + <category domain="http://developmentseed.org/tags/faceted-search">faceted search</category> + <category domain="http://developmentseed.org/tags/intranet">intranet</category> + <category domain="http://developmentseed.org/tags/map-basec-browser">map-basec browser</category> + <category domain="http://developmentseed.org/tags/mapbox">mapbox</category> + <category domain="http://developmentseed.org/tags/open-atrium">open atrium</category> + <category domain="http://developmentseed.org/tags/world-bank">World Bank</category> + <category domain="http://developmentseed.org/channel/drupal-planet">Drupal planet</category> + <pubDate>Fri, 02 Oct 2009 14:31:04 +0000</pubDate> + <dc:creator>Development Seed</dc:creator> + <guid isPermaLink="false">972 at http://developmentseed.org</guid> + </item> + <item> + <title>September GeoDC Meetup Tonight</title> + <link>http://developmentseed.org/blog/2009/sep/30/september-geodc-meetup-tonight</link> + <description><div class="field field-type-text field-field-subtitle"> + <div class="field-items"> + <div class="field-item odd"> + <p>Presentations on Using Amazon&#8217;s Web Services and OpenStreet Map and an iPhone App that Maps Government Data</p> </div> + </div> +</div> +<div class='node-body'><p>Today is the last Wednesday of the month, which means it's time for another <a href="http://geo-dc.ning.com/xn/detail/3537548:Event:1223?xg_source=activity">GeoDC meetup</a>.</p> + +<p><img src="http://farm4.static.flickr.com/3525/3966592859_f7f4cb179c.jpg" alt="September GeoDC Meetup" /></p> + +<p>There will be two short presentations at the meetup. <a href="http://developmentseed.org/team/tom-macwright">Tom MacWright</a> from Development Seed will talk about how using Amazon's web services and <a href="http://www.openstreetmap.org/">OpenStreetMap</a> has helped our mapping team design, render, and host custom maps. Brian Sobel of <a href="http://www.innovationgeo.com/">Innovation Geo</a> will present <a href="http://areyousafedc.com/">Are You Safe</a>, an iPhone App that uses open government data to give users up-to-date and hyper-local information about crime.</p> + +<p>The meetup will run from 7:00 to 9:00 pm at the offices of <a href="http://www.fortiusone.com">Fortius One</a> at 2200 Wilson Blvd, Suite 307 in Arlington, just a <a href="http://maps.google.com/maps?f=q&amp;source=s_q&amp;hl=en&amp;geocode=&amp;q=2200+Wilson+Blvd+%23+307+Arlington,+VA+22201-3324&amp;sll=38.893037,-77.072783&amp;sspn=0.039481,0.087633&amp;ie=UTF8&amp;ll=38.8912,-77.086236&amp;spn=0.009871,0.021908&amp;t=h&amp;z=16&amp;iwloc=A">short walk from the Courthouse metro stop on the orange line</a>. Hope to see you there!</p></div></description> + <comments>http://developmentseed.org/blog/2009/sep/30/september-geodc-meetup-tonight#comments</comments> + <category domain="http://developmentseed.org/tags/geodc">GeoDC</category> + <category domain="http://developmentseed.org/tags/washington-dc">Washington DC</category> + <pubDate>Wed, 30 Sep 2009 12:02:53 +0000</pubDate> + <dc:creator>Development Seed</dc:creator> + <guid isPermaLink="false">971 at http://developmentseed.org</guid> + </item> + <item> + <title>Week in DC Tech: September 28th Edition</title> + <link>http://developmentseed.org/blog/2009/sep/28/week-dc-tech-september-28th-edition</link> + <description><div class="field field-type-text field-field-subtitle"> + <div class="field-items"> + <div class="field-item odd"> + <p>Healthcare 2.0, iPhone Development, Online Storytelling, and More This Week in Washington, DC</p> </div> + </div> +</div> +<div class='node-body'><p><img src="http://developmentseed.org/sites/developmentseed.org/files/dctech2_0_0.png" alt="Week in DC Tech" /></p> + +<p>Looking to geek out this week? There are a bunch of interesting technology events happening in Washington, DC this week, including a look at how social media is impacting healthcare, a screening of online clips from a journalist/filmmaker, and lightning talks on all kinds of geekery by the HacDC folks. Below are the events that caught our eye, and you can find a full list of what's happening this week in technology over at <a href="http://www.dctechevents.com/">DC Tech Events</a>. Have a great week!</p> + +<h1>Tuesday, September 29</h1> + +<p>6:00 pm</p> + +<p><a href="http://www.meetup.com/DC-MD-VA-Health-2-0/calendar/11291017/"><strong>Health 2.0 Meetup</strong></a>: Curious as to how - and if - online technologies are impacting healthcare? At this meetup two speakers - Sanjay Koyani from the U.S. Food and Drug Administration and Taylor Walsh from MetroHealth Media - will talk about what they're seeing and implementing.</p> + +<p>7:00 pm</p> + +<p><a href="http://nscodernightdc.com/"><strong>NSCoderNightDC</strong></a>: Want to build an iphone app, or talk about one that you've already built? Come out for this meetup to talk about mac and iphone development, share the latest news from Apple, and eat some delicious French desserts.</p></div></description> + <comments>http://developmentseed.org/blog/2009/sep/28/week-dc-tech-september-28th-edition#comments</comments> + <category domain="http://developmentseed.org/tags/washington-dc">Washington DC</category> + <pubDate>Mon, 28 Sep 2009 15:33:15 +0000</pubDate> + <dc:creator>Development Seed</dc:creator> + <guid isPermaLink="false">970 at http://developmentseed.org</guid> + </item> + <item> + <title>Open Data for Microfinance: The New MIXMarket.org</title> + <link>http://developmentseed.org/blog/2009/sep/24/open-data-microfinance-new-mixmarketorg</link> + <description><div class="field field-type-text field-field-subtitle"> + <div class="field-items"> + <div class="field-item odd"> + <p>Relaunch focuses on rich data visualization and downloadable data</p> </div> + </div> +</div> +<div class='node-body'><p>The launch of the new <a href="http://www.mixmarket.org/">MIX Market</a> is a big win for open data in international development, and it vastly improves how rich financial data sets can be accessed. The MIX Market is like a Bloomberg for microfinance, publishing data on more than 1,500 microfinance institutions (MFIs) in more than 190 countries and affecting 80,021,351 people. Additionally, each MFI has as many as 150 financial indicators and in some cases going back as far as 1995. The goal of this tool is simple - to open this information up to help MFIs, researchers, raters, evaluators, and governmental and regulatory agencies better see the marketplace, and that makes for better international development.</p> + +<p>There are profiles for every country that the MIX Market is hosting. Take a look at the country landing page for India, showing how India stacks up to other peer groups and listing out all MFIs, networks, and funders and service providers.</p> + +<p><img src="http://farm4.static.flickr.com/3517/3941870722_390f5aa65d.jpg" alt="Country profiles give a quick overview of the performance of its microfinance institutions." /></p></div></description> + <comments>http://developmentseed.org/blog/2009/sep/24/open-data-microfinance-new-mixmarketorg#comments</comments> + <category domain="http://developmentseed.org/tags/data-visualization">data visualization</category> + <category domain="http://developmentseed.org/tags/graphs">graphs</category> + <category domain="http://developmentseed.org/tags/microfinance">microfinance</category> + <category domain="http://developmentseed.org/tags/mix-market">MIX Market</category> + <category domain="http://developmentseed.org/tags/open-data">open data</category> + <category domain="http://developmentseed.org/tags/salesforce">salesforce</category> + <category domain="http://developmentseed.org/channel/drupal-planet">Drupal planet</category> + <pubDate>Thu, 24 Sep 2009 13:09:10 +0000</pubDate> + <dc:creator>Development Seed</dc:creator> + <guid isPermaLink="false">969 at http://developmentseed.org</guid> + </item> + <item> + <title>Integrating the Siteminder Access System in an Open Atrium-based Intranet</title> + <link>http://developmentseed.org/blog/2009/sep/22/integrating-siteminder-access-system-open-atrium-based-intranet</link> + <description><div class="field field-type-text field-field-subtitle"> + <div class="field-items"> + <div class="field-item odd"> + <p>Upgraded Siteminder Module in Drupal allows for better integration with Siteminder </p> </div> + </div> +</div> +<div class='node-body'><p>In <a href="http://developmentseed.org/blog/2009/sep/08/custom-open-atrium-intranet-launches-world-bank">our recent work on the World Bank's Communicate intranet</a>, we needed to integrate the <a href="http://www.ca.com/us/internet-access-control.aspx">Siteminder access system</a> into the <a href="http://openatrium.com/">Open Atrium</a>-based intranet "Communicate" to allow World Bank staff to use the same single sign-on credentials that they use to access all their internal web systems. To do this, we upgraded the Siteminder module for Drupal. You can download the <a href="http://drupal.org/project/siteminder">new module from its Drupal project page</a> and <a href="http://cvs.drupal.org/viewvc.py/drupal/contributions/modules/siteminder/README.txt?revision=1.2&amp;view=markup&amp;pathrev=DRUPAL-6--1-0-ALPHA1">learn more about its API and how to write your own Siteminder plugin in its documentation</a> and from reading the module's code. First, here is a little more background on the changes.</p> + +<p>The Siteminder system, from <a href="http://www.ca.com/us/">Computer Associates</a>, is used by many enterprise-level organizations to authenticate signing on to their web resources. How it works is that you can designate a site - like an Open Atrium powered intranet - to be protected by the Siteminder system. Once a site is protected by Siteminder, all traffic to that site is routed through Siteminder first and then on to the actual site. Siteminder sets certain HTTP headers in the user's request, and Drupal can then examine them to determine credentials. What the Drupal Siteminder module does is map the Siteminder header values to Drupal users and allow a user to login based on the headers they send.</p> + +<p>In addition to authentication, the Siteminder system also stores other information about users. When the Siteminder system sends HTTP headers for authentication, it can also send information about a user - like her name, email address, phone number, and so on. We wanted to be able to pull this information into the intranet too. To achieve this, we re-wrote the Siteminder module in such a way that it's easy to write a plugin module to provide the fields to which you'd like to map this extra Siteminder meta information and to determine how this information is processed and saved. To do this for the World Bank's intranet, we built the Siteminder Profile module, which lets you pick a CCK node type to serve as the target content profile for a user as well as select a few taxonomy vocabularies. Then by using the main module's administrative interface, you can choose which Siteminder headers should get mapped to which CCK fields and vocabularies based on the designated node type and vocabularies you selected in the Siteminder Profile settings page.</p> + +<p>But what happens if a person's information changes in the Siteminder database - for example if they change phone numbers or office buildings? The Siteminder module now has built-in capability and an API to check whether values in users' profiles have changed in the Siteminder system. The Siteminder Profile module uses this API and saves a new version of a user's profile if it detects that a value has changed in the Siteminder system database.</p></div></description> + <comments>http://developmentseed.org/blog/2009/sep/22/integrating-siteminder-access-system-open-atrium-based-intranet#comments</comments> + <category domain="http://developmentseed.org/tags/authentication">authentication</category> + <category domain="http://developmentseed.org/tags/drupal">Drupal</category> + <category domain="http://developmentseed.org/tags/open-atrium">open atrium</category> + <category domain="http://developmentseed.org/tags/siteminder">siteminder</category> + <category domain="http://developmentseed.org/tags/siteminder-module">siteminder module</category> + <category domain="http://developmentseed.org/channel/drupal-planet">Drupal planet</category> + <pubDate>Tue, 22 Sep 2009 18:02:21 +0000</pubDate> + <dc:creator>Development Seed</dc:creator> + <guid isPermaLink="false">964 at http://developmentseed.org</guid> + </item> + <item> + <title>Week in DC Tech: September 21 Edition</title> + <link>http://developmentseed.org/blog/2009/sep/21/week-dc-tech-september-21-edition</link> + <description><div class="field field-type-text field-field-subtitle"> + <div class="field-items"> + <div class="field-item odd"> + <p>PHP, Design, Twitter, and Wikipedia This Week in Washington, DC</p> </div> + </div> +</div> +<div class='node-body'><p><img src="http://developmentseed.org/sites/default/files/dctech2_0.png" alt="Week in DC Tech" /></p> + +<p>There's an interesting variety of technology events happening in Washington, DC this week with focuses ranging from using Twitter for advocacy to drinking beers with php developers to discussing designing way outside of the box. Additionally tomorrow is international Car Free Day and there are events happening throughout the city to celebrate it and help you how to rely on cars less. Below are the events that caught our eye, and you can find a full list of the week's technology events at <a href="http://www.dctechevents.com/">DC Tech Events</a>.</p> + +<h2>Tuesday, September 22</h2> + +<p>All day</p> + +<p><a href="http://www.carfreemetrodc.com/"><strong>Car Free Day</strong></a>: Help reduce traffic and improve air quality by leaving your car at home on Tuesday in celebration of Car Free Day. There are also <a href="http://www.carfreemetrodc.com/Information/tabid/57/Default.aspx">free bike repair trainings, yoga classes, and other events</a> happening throughout the day to help you lead a car free lifestyle.</p> + +<p>6:00 - 8:00 pm</p> + +<p><a href="http://www.php.net/cal.php?id=3075"><strong>DC PHP Beverage Subgroup</strong></a>: Come out to talk code with other php developers over a few beers. This is a great opportunity to get to know local php developers in a casual setting while sharing stories about your code.</p></div></description> + <comments>http://developmentseed.org/blog/2009/sep/21/week-dc-tech-september-21-edition#comments</comments> + <category domain="http://developmentseed.org/tags/washington-dc">Washington DC</category> + <pubDate>Mon, 21 Sep 2009 16:03:24 +0000</pubDate> + <dc:creator>Development Seed</dc:creator> + <guid isPermaLink="false">968 at http://developmentseed.org</guid> + </item> + <item> + <title>Peru's Software Freedom Day: Impressions & Photos</title> + <link>http://developmentseed.org/blog/2009/sep/21/perus-software-freedom-day-impressions-and-photos</link> + <description><div class='node-body'><p>There was a great turn out a <a href="http://www.sfdperu.org/">Software Freedom Day</a> this weekend with 400 people in attendance and a solid 30 presentations. The <a href="http://developmentseed.org/blog/2009/sep/15/preparing-perus-software-freedom-day-talks-drupal-features-and-open-atrium">presentations in the Drupal track</a> were some of the best attended sessions of the day. To get a sense of Drupal's traction down here, "Drupal" was mentioned in many sessions and conversations throughout the day, and not just by the people working directly with Drupal.</p> + +<p><img src="http://farm3.static.flickr.com/2653/3940335249_57ce995a84.jpg" alt="Presenting on Features in Drupal and Open Atrium" /> +<em>Presenting on Features in Drupal and Open Atrium</em></p> + +<p>I had a great time meeting people and learning about the work being done in the different open source communities here in Peru. Software Freedom Day is becoming an annual event in Peru, and there were many discussions on improving the event for next year as well as keeping the energy going to improve the image of open source software in the country. It's great to see the community looking forward like this, and I'm excited to help keep the open source movement growing in Peru.</p> + +<p><a href="http://www.flickr.com/photos/developmentseed/sets/72157622423999830/">More photos from the event here.</a></p></div></description> + <comments>http://developmentseed.org/blog/2009/sep/21/perus-software-freedom-day-impressions-and-photos#comments</comments> + <category domain="http://developmentseed.org/tags/drupal">Drupal</category> + <category domain="http://developmentseed.org/tags/open-source">open source</category> + <category domain="http://developmentseed.org/tags/peru">Peru</category> + <category domain="http://developmentseed.org/tags/software-freedom-day">software freedom day</category> + <pubDate>Mon, 21 Sep 2009 14:22:35 +0000</pubDate> + <dc:creator>Development Seed</dc:creator> + <guid isPermaLink="false">967 at http://developmentseed.org</guid> + </item> + <item> + <title>Scaling the Open Atrium UI</title> + <link>http://developmentseed.org/blog/2009/sep/18/scaling-open-atrium-ui</link> + <description><div class="field field-type-text field-field-subtitle"> + <div class="field-items"> + <div class="field-item odd"> + <p>Refactoring a user interface for bigger, broader use cases</p> </div> + </div> +</div> +<div class='node-body'><p>We released <a href="http://developmentseed.org/blog/2009/jul/14/open-atrium-public-beta-code-github">Open Atrium Beta 1</a> in July knowing that a wider audience would lead to more real world testing, feedback, and problems. In <a href="http://openatrium.com/download">the latest release this week</a>, we've incorporated some of the responses we've gotten from the <a href="http://community.openatrium.com">community</a>, as well as our <a href="http://developmentseed.org/blog/2009/sep/08/custom-open-atrium-intranet-launches-world-bank">clients' experiences</a> into key changes in Open Atrium's UI.</p> + +<p><img src="http://farm3.static.flickr.com/2607/3928674475_285044e13c.jpg" alt=" Fluid width/ Breadcrumbs" /></p> + +<h2>Fluid width</h2> + +<p>The first major change is switching from the previously <code>960px</code> fixed width theme Ginkgo to fluid width. Open Atrium is now usable on screens from <code>800px</code> wide to as-big-as-your-budget-allows. Aside from meaning that the page layout stretches and shrinks when you resize your browser window, it also means slightly bigger/more readable fonts overall.</p> + +<h2>1. Breadcrumbs</h2> + +<p>One usability problem we often deal with is people not recognizing the site / group / user space architecture of Open Atrium. One approach to this in the past was to color-code different space types. With the color-customizations made possible by <code>spaces_design</code>, this is no longer necessarily a reliable indicator of where you are. We first introduced breadcrumbs in Open Atrium on the Communicate project for the World Bank and have since merged it into Atrium HEAD.</p> + +<h2>2. Consolidate blocks, user info, and help</h2> + +<p>The global tools in Open Atrium's first row of navigation have been consolidated into distinct blocks. Previously, some header links were inserted into the page template through custom <code>preprocess_page()</code> calls, while the togglable help text was inserted via a custom theme function and dropdown blocks were added into the header region. All of these components are now provided by blocks, making it straightforward for themers and developers to adjust, remove, or add to these components.</p> + +<p><img src="http://farm3.static.flickr.com/2654/3928674449_8f443df1b3.jpg" alt="Togglable Open Atrium UI adjustments" /></p></div></description> + <comments>http://developmentseed.org/blog/2009/sep/18/scaling-open-atrium-ui#comments</comments> + <category domain="http://developmentseed.org/tags/drupal">Drupal</category> + <category domain="http://developmentseed.org/tags/interface">interface</category> + <category domain="http://developmentseed.org/tags/open-atrium">open atrium</category> + <category domain="http://developmentseed.org/tags/usability">usability</category> + <category domain="http://developmentseed.org/channel/drupal-planet">Drupal planet</category> + <pubDate>Fri, 18 Sep 2009 14:31:23 +0000</pubDate> + <dc:creator>Development Seed</dc:creator> + <guid isPermaLink="false">966 at http://developmentseed.org</guid> + </item> + </channel> +</rss> \ No newline at end of file diff --git a/sites/all/modules/feeds/tests/feeds/developmentseed_changes.rss2 b/sites/all/modules/feeds/tests/feeds/developmentseed_changes.rss2 new file mode 100644 index 0000000000000000000000000000000000000000..f49d410e5a99ff836cb3847676a1b305b17b63df --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds/developmentseed_changes.rss2 @@ -0,0 +1,299 @@ +<?xml version="1.0" encoding="utf-8" ?><rss version="2.0" xml:base="http://developmentseed.org/blog/all" xmlns:dc="http://purl.org/dc/elements/1.1/"> + <channel> + <title>Development Seed - Technological Solutions for Progressive Organizations</title> + <link>http://developmentseed.org/blog/all</link> + <description></description> + <language>en</language> + <item> + <title>Managing News Translation Workflow: Two Way Translation Updates</title> + <link>http://developmentseed.org/blog/2009/oct/06/open-atrium-translation-workflow-two-way-updating</link> + <description><div class="field field-type-text field-field-subtitle"> + <div class="field-items"> + <div class="field-item odd"> + <p>A new translation process for Open Atrium and integration with Localize Drupal</p> </div> + </div> +</div> +<div class='node-body'><p>The <a href="http://openatrium.com/">Open Atrium</a> <a href="http://developmentseed.org/blog/2009/jul/16/open-atrium-solving-translation-puzzle">translation infrastructure</a> (and Drupal translations in general) are progressing quickly. For Open Atrium to be well translated we first need Drupal's modules to be translated, so I am splitting efforts at the moment between helping with <a href="http://localize.drupal.org">Localize Drupal</a> and improving <a href="https://translate.openatrium.com">Open Atrium Translate</a>. Already, it is much easier to automatically download your language, get updates from a translation server, protect locally translated strings, and scale the translation system so that translation servers can talk to each other.</p> + +<h1>Automatically download your language</h1> + +<p><img src="http://farm3.static.flickr.com/2496/3984689117_57559c74eb.jpg" alt="Magical translation install" /></p> + +<p>For more than a month now you have been able to install Open Atrium, select one of its 20+ languages, and have the translation automatically downloaded and installed on your site. While this has been working for awhile now, we are still refining the process. One change we've already made is that now translations are downloaded in multiple smaller packages, rather than a single large one.</p> + +<p>Once your translation is installed, you can use tools like the <a href="http://drupal.org/project/l10n_client">Localization client</a>, which comes bundled in the Open Atrium install, to translate page strings and then optionally contribute them back to the localization server, automatically. This flow of translations goes both ways, so your site gets the latest updates from the server just as you can send your latest updates to the server.</p> + +<h1>Two way translation updates</h1> + +<p><em>But what happens with my locally translated strings, which I like more than the ones that come out of the box, when I update from the server?</em></p> + +<p><img src="http://farm3.static.flickr.com/2442/3984689343_e9b7c32718.jpg" alt="Two ways translation updates" /></p> + +<p>In a word, nothing. There has been a major improvement on this front. Now your translations are tracked and won't be overwritten by someone else's translations when you update, unless you choose for them to be. This means that you can contribute your translations, benefit from others contributing theirs, and make the world a better (translated) place, without loosing any custom work that you want. Let the translations flow!</p></div></description> + <comments>http://developmentseed.org/blog/2009/oct/06/open-atrium-translation-workflow-two-way-updating#comments</comments> + <category domain="http://developmentseed.org/tags/drupal">Drupal</category> + <category domain="http://developmentseed.org/tags/localization">localization</category> + <category domain="http://developmentseed.org/tags/localization-client">localization client</category> + <category domain="http://developmentseed.org/tags/localization-server">localization server</category> + <category domain="http://developmentseed.org/tags/open-atrium">open atrium</category> + <category domain="http://developmentseed.org/tags/translation">translation</category> + <category domain="http://developmentseed.org/tags/translation-server">translation server</category> + <category domain="http://developmentseed.org/channel/drupal-planet">Drupal planet</category> + <pubDate>Tue, 06 Oct 2009 15:21:48 +0000</pubDate> + <dc:creator>Development Seed</dc:creator> + <guid isPermaLink="false">974 at http://developmentseed.org</guid> + </item> + <item> + <title>Week in DC Tech: October 5th Edition</title> + <link>http://developmentseed.org/blog/2009/oct/05/week-dc-tech-october-5th-edition</link> + <description><div class="field field-type-text field-field-subtitle"> + <div class="field-items"> + <div class="field-item odd"> + <p>Drupal, PHP, and Mapping This Week in Washington, DC</p> </div> + </div> +</div> +<div class='node-body'><p><img src="http://developmentseed.org/sites/developmentseed.org/files/dctech2_0_0.png" alt="Week in DC Tech" /></p> + +<p>There are some great technology events happening this week in Washington, DC, so if you're looking to talk code, help map the city, or just hear about some neat projects and ideas, you're in luck. Below are the events that caught our eye, and you can find a full list of technology events happening this week at <a href="http://www.dctechevents.com/">DC Tech Events</a>. Have a great week!</p> + +<h1>Wednesday, October 7</h1> + +<p>6:30 pm</p> + +<p><a href="http://drupal.meetup.com/21/calendar/11332695/"><strong>NOVA Drupal Meetup</strong></a>: Are you a Drupal developer, use a Drupal site, or just want to learn more about the open source content management system? Come out for this meetup to meet other Drupal fans and hear how people are using the CMS.</p> + +<p>6:30 pm</p> + +<p><a href="http://groups.google.com/group/washington-dcphp-group/browse_thread/thread/716d4a625287fef5?hl=en"><strong>DC PHP Beverage Subgroup</strong></a>: If you're looking to talk code with PHP developers who share your interest, come out for this casual meetup to chat over beers.</p> + +<p>7:00 pm</p> + +<p><a href="http://hacdc.org/"><strong>Mapping DC Meeting</strong></a>: Come out for this meetup if you want to help create high quality, free maps of Washington, DC. The <a href="http://wiki.openstreetmap.org/wiki/MappingDC">Mapping DC</a> group is doing this, starting with creating a detailed map of the National Zoo.</p></div></description> + <comments>http://developmentseed.org/blog/2009/oct/05/week-dc-tech-october-5th-edition#comments</comments> + <category domain="http://developmentseed.org/tags/washington-dc">Washington DC</category> + <pubDate>Mon, 05 Oct 2009 15:27:40 +0000</pubDate> + <dc:creator>Development Seed</dc:creator> + <guid isPermaLink="false">973 at http://developmentseed.org</guid> + </item> + <item> + <title>Mapping Innovation at the World Bank with Open Atrium</title> + <link>http://developmentseed.org/blog/2009/oct/02/mapping-innovation-world-bank-open-atrium</link> + <description><div class="field field-type-text field-field-subtitle"> + <div class="field-items"> + <div class="field-item odd"> + <p>Using map and faceted search features to improve collaboration</p> </div> + </div> +</div> +<div class='node-body'><p><a href="http://openatrium.com/">Open Atrium</a> is being used as a base platform for collaboration at the World Bank because of its feature flexibility. Last week the World Bank launched a new Open Atrium site called "Innovate," which is being used to support an organization-wide initiative to better share information about successful projects and approaches to solving problems.</p> + +<p>The core of the site is built around helping World Bank staff discover relevant "innovations" happening around the world and providing a space to discuss them with colleagues in topical discussion groups. To facilitate this workflow we built a custom map-based browser feature that combines custom maps with faceted search, letting users quickly find interesting content. The screenshots below from a staging site with a partial database show what this feature looks like.</p> + +<p><img src="http://farm4.static.flickr.com/3419/3974644312_c992e1afe8.jpg" alt="The map-based browser feature makes custom maps with faceted search" /></p> + +<p>As users apply new facets to their searches, the map results update to reveal global coverage for innovations that meet the search criteria.</p> + +<p><img src="http://farm3.static.flickr.com/2600/3974644162_a44cc3a89a.jpg" alt="Add new facets to the search to further customize the map" /></p></div></description> + <comments>http://developmentseed.org/blog/2009/oct/02/mapping-innovation-world-bank-open-atrium#comments</comments> + <category domain="http://developmentseed.org/tags/custom-mapping">custom mapping</category> + <category domain="http://developmentseed.org/tags/drupal">Drupal</category> + <category domain="http://developmentseed.org/tags/faceted-search">faceted search</category> + <category domain="http://developmentseed.org/tags/intranet">intranet</category> + <category domain="http://developmentseed.org/tags/map-basec-browser">map-basec browser</category> + <category domain="http://developmentseed.org/tags/mapbox">mapbox</category> + <category domain="http://developmentseed.org/tags/open-atrium">open atrium</category> + <category domain="http://developmentseed.org/tags/world-bank">World Bank</category> + <category domain="http://developmentseed.org/channel/drupal-planet">Drupal planet</category> + <pubDate>Fri, 02 Oct 2009 14:31:04 +0000</pubDate> + <dc:creator>Development Seed</dc:creator> + <guid isPermaLink="false">972 at http://developmentseed.org</guid> + </item> + <item> + <title>September GeoDC Meetup Tonight</title> + <link>http://developmentseed.org/blog/2009/sep/30/september-geodc-meetup-tonight</link> + <description><div class="field field-type-text field-field-subtitle"> + <div class="field-items"> + <div class="field-item odd"> + <p>Presentations on Using Amazon&#8217;s Web Services and OpenStreet Map and an iPhone App that Maps Government Data</p> </div> + </div> +</div> +<div class='node-body'><p>Today is the last Wednesday of the month, which means it's time for another <a href="http://geo-dc.ning.com/xn/detail/3537548:Event:1223?xg_source=activity">GeoDC meetup</a>.</p> + +<p><img src="http://farm4.static.flickr.com/3525/3966592859_f7f4cb179c.jpg" alt="September GeoDC Meetup" /></p> + +<p>There will be two short presentations at the meetup. <a href="http://developmentseed.org/team/tom-macwright">Tom MacWright</a> from Development Seed will talk about how using Amazon's web services and <a href="http://www.openstreetmap.org/">OpenStreetMap</a> has helped our mapping team design, render, and host custom maps. Brian Sobel of <a href="http://www.innovationgeo.com/">Innovation Geo</a> will present <a href="http://areyousafedc.com/">Are You Safe</a>, an iPhone App that uses open government data to give users up-to-date and hyper-local information about crime.</p> + +<p>The meetup will run from 7:00 to 9:00 pm at the offices of <a href="http://www.fortiusone.com">Fortius One</a> at 2200 Wilson Blvd, Suite 307 in Arlington, just a <a href="http://maps.google.com/maps?f=q&amp;source=s_q&amp;hl=en&amp;geocode=&amp;q=2200+Wilson+Blvd+%23+307+Arlington,+VA+22201-3324&amp;sll=38.893037,-77.072783&amp;sspn=0.039481,0.087633&amp;ie=UTF8&amp;ll=38.8912,-77.086236&amp;spn=0.009871,0.021908&amp;t=h&amp;z=16&amp;iwloc=A">short walk from the Courthouse metro stop on the orange line</a>. Hope to see you there!</p></div></description> + <comments>http://developmentseed.org/blog/2009/sep/30/september-geodc-meetup-tonight#comments</comments> + <category domain="http://developmentseed.org/tags/geodc">GeoDC</category> + <category domain="http://developmentseed.org/tags/washington-dc">Washington DC</category> + <pubDate>Wed, 30 Sep 2009 12:02:53 +0000</pubDate> + <dc:creator>Development Seed</dc:creator> + <guid isPermaLink="false">971 at http://developmentseed.org</guid> + </item> + <item> + <title>Week in DC Tech: September 28th Edition</title> + <link>http://developmentseed.org/blog/2009/sep/28/week-dc-tech-september-28th-edition</link> + <description><div class="field field-type-text field-field-subtitle"> + <div class="field-items"> + <div class="field-item odd"> + <p>Healthcare 2.0, iPhone Development, Online Storytelling, and More This Week in Washington, DC</p> </div> + </div> +</div> +<div class='node-body'><p><img src="http://developmentseed.org/sites/developmentseed.org/files/dctech2_0_0.png" alt="Week in DC Tech" /></p> + +<p>Looking to geek out this week? There are a bunch of interesting technology events happening in Washington, DC this week, including a look at how social media is impacting healthcare, a screening of online clips from a journalist/filmmaker, and lightning talks on all kinds of geekery by the HacDC folks. Below are the events that caught our eye, and you can find a full list of what's happening this week in technology over at <a href="http://www.dctechevents.com/">DC Tech Events</a>. Have a great week!</p> + +<h1>Tuesday, September 29</h1> + +<p>6:00 pm</p> + +<p><a href="http://www.meetup.com/DC-MD-VA-Health-2-0/calendar/11291017/"><strong>Health 2.0 Meetup</strong></a>: Curious as to how - and if - online technologies are impacting healthcare? At this meetup two speakers - Sanjay Koyani from the U.S. Food and Drug Administration and Taylor Walsh from MetroHealth Media - will talk about what they're seeing and implementing.</p> + +<p>7:00 pm</p> + +<p><a href="http://nscodernightdc.com/"><strong>NSCoderNightDC</strong></a>: Want to build an iphone app, or talk about one that you've already built? Come out for this meetup to talk about mac and iphone development, share the latest news from Apple, and eat some delicious French desserts.</p></div></description> + <comments>http://developmentseed.org/blog/2009/sep/28/week-dc-tech-september-28th-edition#comments</comments> + <category domain="http://developmentseed.org/tags/washington-dc">Washington DC</category> + <pubDate>Mon, 28 Sep 2009 15:33:15 +0000</pubDate> + <dc:creator>Development Seed</dc:creator> + <guid isPermaLink="false">970 at http://developmentseed.org</guid> + </item> + <item> + <title>Open Data for Microfinance: The New MIXMarket.org</title> + <link>http://developmentseed.org/blog/2009/sep/24/open-data-microfinance-new-mixmarketorg</link> + <description><div class="field field-type-text field-field-subtitle"> + <div class="field-items"> + <div class="field-item odd"> + <p>Relaunch focuses on rich data visualization and downloadable data</p> </div> + </div> +</div> +<div class='node-body'><p>The launch of the new <a href="http://www.mixmarket.org/">MIX Market</a> is a big win for open data in international development, and it vastly improves how rich financial data sets can be accessed. The MIX Market is like a Bloomberg for microfinance, publishing data on more than 1,500 microfinance institutions (MFIs) in more than 190 countries and affecting 80,021,351 people. Additionally, each MFI has as many as 150 financial indicators and in some cases going back as far as 1995. The goal of this tool is simple - to open this information up to help MFIs, researchers, raters, evaluators, and governmental and regulatory agencies better see the marketplace, and that makes for better international development.</p> + +<p>There are profiles for every country that the MIX Market is hosting. Take a look at the country landing page for India, showing how India stacks up to other peer groups and listing out all MFIs, networks, and funders and service providers.</p> + +<p><img src="http://farm4.static.flickr.com/3517/3941870722_390f5aa65d.jpg" alt="Country profiles give a quick overview of the performance of its microfinance institutions." /></p></div></description> + <comments>http://developmentseed.org/blog/2009/sep/24/open-data-microfinance-new-mixmarketorg#comments</comments> + <category domain="http://developmentseed.org/tags/data-visualization">data visualization</category> + <category domain="http://developmentseed.org/tags/graphs">graphs</category> + <category domain="http://developmentseed.org/tags/microfinance">microfinance</category> + <category domain="http://developmentseed.org/tags/mix-market">MIX Market</category> + <category domain="http://developmentseed.org/tags/open-data">open data</category> + <category domain="http://developmentseed.org/tags/salesforce">salesforce</category> + <category domain="http://developmentseed.org/channel/drupal-planet">Drupal planet</category> + <pubDate>Thu, 24 Sep 2009 13:09:10 +0000</pubDate> + <dc:creator>Development Seed</dc:creator> + <guid isPermaLink="false">969 at http://developmentseed.org</guid> + </item> + <item> + <title>Integrating the Siteminder Access System in an Open Atrium-based Intranet</title> + <link>http://developmentseed.org/blog/2009/sep/22/integrating-siteminder-access-system-open-atrium-based-intranet</link> + <description><div class="field field-type-text field-field-subtitle"> + <div class="field-items"> + <div class="field-item odd"> + <p>Upgraded Siteminder Module in Drupal allows for better integration with Siteminder </p> </div> + </div> +</div> +<div class='node-body'><p>In <a href="http://developmentseed.org/blog/2009/sep/08/custom-open-atrium-intranet-launches-world-bank">our recent work on the World Bank's Communicate intranet</a>, we needed to integrate the <a href="http://www.ca.com/us/internet-access-control.aspx">Siteminder access system</a> into the <a href="http://openatrium.com/">Open Atrium</a>-based intranet "Communicate" to allow World Bank staff to use the same single sign-on credentials that they use to access all their internal web systems. To do this, we upgraded the Siteminder module for Drupal. You can download the <a href="http://drupal.org/project/siteminder">new module from its Drupal project page</a> and <a href="http://cvs.drupal.org/viewvc.py/drupal/contributions/modules/siteminder/README.txt?revision=1.2&amp;view=markup&amp;pathrev=DRUPAL-6--1-0-ALPHA1">learn more about its API and how to write your own Siteminder plugin in its documentation</a> and from reading the module's code. First, here is a little more background on the changes.</p> + +<p>The Siteminder system, from <a href="http://www.ca.com/us/">Computer Associates</a>, is used by many enterprise-level organizations to authenticate signing on to their web resources. How it works is that you can designate a site - like an Open Atrium powered intranet - to be protected by the Siteminder system. Once a site is protected by Siteminder, all traffic to that site is routed through Siteminder first and then on to the actual site. Siteminder sets certain HTTP headers in the user's request, and Drupal can then examine them to determine credentials. What the Drupal Siteminder module does is map the Siteminder header values to Drupal users and allow a user to login based on the headers they send.</p> + +<p>In addition to authentication, the Siteminder system also stores other information about users. When the Siteminder system sends HTTP headers for authentication, it can also send information about a user - like her name, email address, phone number, and so on. We wanted to be able to pull this information into the intranet too. To achieve this, we re-wrote the Siteminder module in such a way that it's easy to write a plugin module to provide the fields to which you'd like to map this extra Siteminder meta information and to determine how this information is processed and saved. To do this for the World Bank's intranet, we built the Siteminder Profile module, which lets you pick a CCK node type to serve as the target content profile for a user as well as select a few taxonomy vocabularies. Then by using the main module's administrative interface, you can choose which Siteminder headers should get mapped to which CCK fields and vocabularies based on the designated node type and vocabularies you selected in the Siteminder Profile settings page.</p> + +<p>But what happens if a person's information changes in the Siteminder database - for example if they change phone numbers or office buildings? The Siteminder module now has built-in capability and an API to check whether values in users' profiles have changed in the Siteminder system. The Siteminder Profile module uses this API and saves a new version of a user's profile if it detects that a value has changed in the Siteminder system database.</p></div></description> + <comments>http://developmentseed.org/blog/2009/sep/22/integrating-siteminder-access-system-open-atrium-based-intranet#comments</comments> + <category domain="http://developmentseed.org/tags/authentication">authentication</category> + <category domain="http://developmentseed.org/tags/drupal">Drupal</category> + <category domain="http://developmentseed.org/tags/open-atrium">open atrium</category> + <category domain="http://developmentseed.org/tags/siteminder">siteminder</category> + <category domain="http://developmentseed.org/tags/siteminder-module">siteminder module</category> + <category domain="http://developmentseed.org/channel/drupal-planet">Drupal planet</category> + <pubDate>Tue, 22 Sep 2009 18:02:21 +0000</pubDate> + <dc:creator>Development Seed</dc:creator> + <guid isPermaLink="false">964 at http://developmentseed.org</guid> + </item> + <item> + <title>Week in DC Tech: September 21 Edition</title> + <link>http://developmentseed.org/blog/2009/sep/21/week-dc-tech-september-21-edition</link> + <description><div class="field field-type-text field-field-subtitle"> + <div class="field-items"> + <div class="field-item odd"> + <p>PHP, Design, Twitter, and Wikipedia This Week in Washington, DC</p> </div> + </div> +</div> +<div class='node-body'><p><img src="http://developmentseed.org/sites/default/files/dctech2_0.png" alt="Week in DC Tech" /></p> + +<p>There's an interesting variety of technology events happening in Washington, DC this week with focuses ranging from using Twitter for advocacy to drinking beers with php developers to discussing designing way outside of the box. Additionally tomorrow is international Car Free Day and there are events happening throughout the city to celebrate it and help you how to rely on cars less. Below are the events that caught our eye, and you can find a full list of the week's technology events at <a href="http://www.dctechevents.com/">DC Tech Events</a>.</p> + +<h2>Tuesday, September 22</h2> + +<p>All day</p> + +<p><a href="http://www.carfreemetrodc.com/"><strong>Car Free Day</strong></a>: Help reduce traffic and improve air quality by leaving your car at home on Tuesday in celebration of Car Free Day. There are also <a href="http://www.carfreemetrodc.com/Information/tabid/57/Default.aspx">free bike repair trainings, yoga classes, and other events</a> happening throughout the day to help you lead a car free lifestyle.</p> + +<p>6:00 - 8:00 pm</p> + +<p><a href="http://www.php.net/cal.php?id=3075"><strong>DC PHP Beverage Subgroup</strong></a>: Come out to talk code with other php developers over a few beers. This is a great opportunity to get to know local php developers in a casual setting while sharing stories about your code.</p></div></description> + <comments>http://developmentseed.org/blog/2009/sep/21/week-dc-tech-september-21-edition#comments</comments> + <category domain="http://developmentseed.org/tags/washington-dc">Washington DC</category> + <pubDate>Mon, 21 Sep 2009 16:03:24 +0000</pubDate> + <dc:creator>Development Seed</dc:creator> + <guid isPermaLink="false">968 at http://developmentseed.org</guid> + </item> + <item> + <title>Peru's Software Freedom Day: Impressions and Photos</title> + <link>http://developmentseed.org/blog/2009/sep/21/perus-software-freedom-day-impressions-and-photos</link> + <description><div class='node-body'><p>There was a great turn out a <a href="http://www.sfdperu.org/">Software Freedom Day</a> this weekend with 400 people in attendance and a solid 30 presentations. The <a href="http://developmentseed.org/blog/2009/sep/15/preparing-perus-software-freedom-day-talks-drupal-features-and-open-atrium">presentations in the Drupal track</a> were some of the best attended sessions of the day. To get a sense of Drupal's traction down here, "Drupal" was mentioned in many sessions and conversations throughout the day, and not just by the people working directly with Drupal.</p> + +<p><img src="http://farm3.static.flickr.com/2653/3940335249_57ce995a84.jpg" alt="Presenting on Features in Drupal and Managing News" /> +<em>Presenting on Features in Drupal and Managing News</em></p> + +<p>I had a great time meeting people and learning about the work being done in the different open source communities here in Peru. Software Freedom Day is becoming an annual event in Peru, and there were many discussions on improving the event for next year as well as keeping the energy going to improve the image of open source software in the country. It's great to see the community looking forward like this, and I'm excited to help keep the open source movement growing in Peru.</p> + +<p><a href="http://www.flickr.com/photos/developmentseed/sets/72157622423999830/">More photos from the event here.</a></p></div></description> + <comments>http://developmentseed.org/blog/2009/sep/21/perus-software-freedom-day-impressions-and-photos#comments</comments> + <category domain="http://developmentseed.org/tags/drupal">Drupal</category> + <category domain="http://developmentseed.org/tags/open-source">open source</category> + <category domain="http://developmentseed.org/tags/peru">Peru</category> + <category domain="http://developmentseed.org/tags/software-freedom-day">software freedom day</category> + <pubDate>Mon, 21 Sep 2009 14:22:35 +0000</pubDate> + <dc:creator>Development Seed</dc:creator> + <guid isPermaLink="false">967 at http://developmentseed.org</guid> + </item> + <item> + <title>Scaling the Open Atrium UI</title> + <link>http://developmentseed.org/blog/2009/sep/18/scaling-open-atrium-ui</link> + <description><div class="field field-type-text field-field-subtitle"> + <div class="field-items"> + <div class="field-item odd"> + <p>Refactoring a user interface for bigger, broader use cases</p> </div> + </div> +</div> +<div class='node-body'><p>We released <a href="http://developmentseed.org/blog/2009/jul/14/open-atrium-public-beta-code-github">Open Atrium Beta 1</a> in July knowing that a wider audience would lead to more real world testing, feedback, and problems. In <a href="http://openatrium.com/download">the latest release this week</a>, we've incorporated some of the responses we've gotten from the <a href="http://community.openatrium.com">community</a>, as well as our <a href="http://developmentseed.org/blog/2009/sep/08/custom-open-atrium-intranet-launches-world-bank">clients' experiences</a> into key changes in Open Atrium's UI.</p> + +<p><img src="http://farm3.static.flickr.com/2607/3928674475_285044e13c.jpg" alt=" Fluid width/ Breadcrumbs" /></p> + +<h2>Fluid width</h2> + +<p>The first major change is switching from the previously <code>960px</code> fixed width theme Ginkgo to fluid width. Open Atrium is now usable on screens from <code>800px</code> wide to as-big-as-your-budget-allows. Aside from meaning that the page layout stretches and shrinks when you resize your browser window, it also means slightly bigger/more readable fonts overall.</p> + +<h2>1. Breadcrumbs</h2> + +<p>One usability problem we often deal with is people not recognizing the site / group / user space architecture of Open Atrium. One approach to this in the past was to color-code different space types. With the color-customizations made possible by <code>spaces_design</code>, this is no longer necessarily a reliable indicator of where you are. We first introduced breadcrumbs in Open Atrium on the Communicate project for the World Bank and have since merged it into Atrium HEAD.</p> + +<h2>2. Consolidate blocks, user info, and help</h2> + +<p>The global tools in Open Atrium's first row of navigation have been consolidated into distinct blocks. Previously, some header links were inserted into the page template through custom <code>preprocess_page()</code> calls, while the togglable help text was inserted via a custom theme function and dropdown blocks were added into the header region. All of these components are now provided by blocks, making it straightforward for themers and developers to adjust, remove, or add to these components.</p> + +<p><img src="http://farm3.static.flickr.com/2654/3928674449_8f443df1b3.jpg" alt="Togglable Open Atrium UI adjustments" /></p></div></description> + <comments>http://developmentseed.org/blog/2009/sep/18/scaling-open-atrium-ui#comments</comments> + <category domain="http://developmentseed.org/tags/drupal">Drupal</category> + <category domain="http://developmentseed.org/tags/interface">interface</category> + <category domain="http://developmentseed.org/tags/open-atrium">open atrium</category> + <category domain="http://developmentseed.org/tags/usability">usability</category> + <category domain="http://developmentseed.org/channel/drupal-planet">Drupal planet</category> + <pubDate>Fri, 18 Sep 2009 14:31:23 +0000</pubDate> + <dc:creator>Development Seed</dc:creator> + <guid isPermaLink="false">966 at http://developmentseed.org</guid> + </item> + </channel> +</rss> \ No newline at end of file diff --git a/sites/all/modules/feeds/tests/feeds/drupalplanet.rss2 b/sites/all/modules/feeds/tests/feeds/drupalplanet.rss2 new file mode 100644 index 0000000000000000000000000000000000000000..c2e0e90a8b66f028b43f4ba73b886d391f4ecb20 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds/drupalplanet.rss2 @@ -0,0 +1,295 @@ +<?xml version="1.0" encoding="utf-8"?> +<rss version="2.0"> +<channel> + <title>drupal.org aggregator</title> + <link>http://drupal.org/planet</link> + <description>drupal.org - aggregated feeds in category Planet Drupal</description> + <language>en</language> +<item> + <title>Adaptivethemes: Why I killed Node, may it RIP</title> + <link>http://adaptivethemes.com/why-i-killed-node-may-it-rip</link> + <description><p>Myself, like many others, have always had an acrimonious relationship with the word &#8220;node&#8221;. It didn&#8217;t exactly get off to a good start when node presented me with a rude &#8220;wtf&#8221; moment when we first met. Things only went down hill after that, node remaining aloof and abstract, without ever just coming out and telling me what it actually&nbsp;was.</p> +<div class="og_rss_groups"></div></description> + <pubDate>Fri, 23 Oct 2009 17:00:46 +0000</pubDate> +</item> +<item> + <title>Midwestern Mac, LLC: Managing News - Revolutionary—not Evolutionary—Step for Drupal</title> + <link>http://www.midwesternmac.com/blogs/geerlingguy/managing-news-revolutionary%E2%80%94not-evolutionary%E2%80%94step-drupal</link> + <description><p>I noticed a post from the excellent folks over at <a href="http://developmentseed.org/">Development Seed</a> in the drupal.org Planet feed on a new Drupal installation profile they've been working on called <a href="http://managingnews.com/">Managing News</a>. Having tried (and loved) their Drupal-based installation of <a href="http://openatrium.com/">Open Atrium</a> (a great package for quick Intranets), I had pretty high expectations.</p> +<p>Those expectations were pretty much blown out of the water; this install profile basically sets up a Drupal site (with all the Drupal bells and whistles) that is focused on one thing, and does it well: <strong>news aggregation via feeds</strong> (Atom, RSS).</p> +<p class="rtecenter"><a href="http://catholicnewslive.com/"><img alt="Catholic News Live.com - Catholic News Aggregator" width="450" height="351" class="noborder" src="http://www.midwesternmac.com/sites/default/files/blogpost-images/catholic-news-live-screenshot.jpg" /></a></p> +<p>I decided to quickly build out an aggregation site, <a href="http://catholicnewslive.com/">Catholic News Live</a>. The site took about 4 hours to set up, and it's already relatively customized to my needs. One thing I still don't know about is whether Drupal's cron will be able to handle the site after a few months and a few hundred more feeds... but we'll see!</p><p><a href="http://www.midwesternmac.com/blogs/geerlingguy/managing-news-revolutionary%E2%80%94not-evolutionary%E2%80%94step-drupal">read more</a></p> +</description> + <pubDate>Fri, 23 Oct 2009 04:58:15 +0000</pubDate> +</item> +<item> + <title>Dries Buytaert: Eén using Drupal</title> + <link>http://buytaert.net/een-using-drupal</link> + <description>Eén (Dutch for 'one'), a public TV station reaching millions of people in Belgium, redesigned its website using <a href="http://drupal.org">Drupal</a>: see <a href="http://een.be">http://een.be</a>. + +<div class="figure"> +<img src="http://buytaert.net/sites/buytaert.net/files/cache/drupal-een-500x500.jpg" alt="Een" style="border: 1px solid #ccc; padding: 4px;"/> + +</div></description> + <pubDate>Fri, 23 Oct 2009 01:30:55 +0000</pubDate> +</item> +<item> + <title>Open Em Space: Em Space's top Drupal 6 modules (that aren't always in the limelight)</title> + <link>http://open.emspace.com.au/article/em-spaces-top-drupal-6-modules-arent-always-limelight</link> + <description><div class="field field-type-filefield field-field-article-image"> + <div class="field-items"> + <div class="field-item odd"> + <img class="imagefield imagefield-field_article_image" width="200" height="200" alt="" src="http://open.emspace.com.au/sites/open.emspace.com.au/files/article_images/Drupal-logo-C366BDF9CE-seeklogo.com_.gif?1256197849" /> </div> + </div> +</div> +<style type="text/css"> + h3 { text-decoration: underline; } + div.item-container { border-top: 1px dotted #CCC; padding-bottom: 10px; } +</style><p> +<strong>Every development house and their dogs seem to have a 'TOP 10 DRUPAL MODULES</strong> - Absolute definitive version!!' blog post somewhere at the minute, and they all tend to be fairly similar - 'Views, CCK, Image' etc... </p> +<p>We have decided to go a different route, and do our own summary of drupal modules (and combinations) that we use all the time, which you may not have used before.</p></description> + <pubDate>Fri, 23 Oct 2009 00:00:54 +0000</pubDate> +</item> +<item> + <title>NodeOne: The new Feeds module</title> + <link>http://nodeone.se/blogg/drupal/new-feeds-module</link> + <description><p>How do you aggregate feeds into a <a href="/drupal">Drupal website</a>? Or import data from other sources like a CSV document? The answer to this is of course <a href="http://drupal.org/project/feedapi">FeedAPI</a>! Or is it?</p> +<p>FeedAPI has for long been the mainstream solution for this kind of problems. And a really good one! But very recently the guys over at <a href="http://developmentseed.org/">Development Seed</a> (the creators and maintainers of FeedAPI) released a new alpha version of the <a href="http://drupal.org/project/feeds">Feeds</a> module. I haven't had time to play around with it too much yet. But it seems to be very promising. The dependency of <a href="http://drupal.org/project/ctools">CTools</a> and its plugin framework makes Feeds a lot more extensible than FeedAPI was. The code base and its API is more thought out and seems to be better prepared for scalability.</p> +<p>I can't wait to use Feeds out in the wild! When I do so, I'll come back with a more in depth review.</p> +<div class="watcher_node"><a href="/user/0/watcher/toggle/345?destination=drupalplanet%2Ffeed" class="watcher_node_toggle_watching_link" title="Watch posts to be notified when other users comment on them or the posts are changed">Du bevakar inte detta inlägg, klicka här för att börja bevaka</a></div></description> + <pubDate>Thu, 22 Oct 2009 18:48:37 +0000</pubDate> +</item> +<item> + <title>Geoff Hankerson: Bring Sanity to Your Web Site (& Your Life)</title> + <link>http://geoffhankerson.com/node/110</link> + <description><p> Only 9 seats left. Sign up at <a href="http://www.strategicit.org/sanity">http://www.strategicit.org/sanity</a></p> +<p><img style="display: block; padding-bottom: 12px;" src="http://farm1.static.flickr.com/188/395226087_9002872142.jpg" border="0" alt="Sanily" /></p> +<h6 style="font-size: 9px; padding-bottom: 12px;">Photo by <a href="http://www.flickr.com/photos/darkpatator/">http://www.flickr.com/photos/darkpatator/</a> / <a href="http://creativecommons.org/licenses/by/2.0/">CC BY 2.0</a></h6> +<p>NO CHARGE :: Class Limited to First 15 Applicants</p> +<p><a href="http://geoffhankerson.com/node/110" target="_blank">read more</a></p></description> + <pubDate>Thu, 22 Oct 2009 18:19:10 +0000</pubDate> +</item> +<item> + <title>Nick Vidal: HTTP and the Push Model</title> + <link>http://nick.iss.im/2009/10/22/http-and-the-push-model/</link> + <description><p>The Real-time Web seems to be the buzz of the moment, and there has been quite a debate comparing HTTP, XMPP and related technologies (Comet, Web sockets). Generally HTTP is associated with the Pull model, while XMPP is associated with the Push model. But it&#8217;s very well possible to design an architecture that follows the Push model using HTTP.</p> +<p>Let&#8217;s see an example to illustrated the point: Nick and Debbie are friends and they have subscribed to each other&#8217;s feed to receive updates. Their feeds are hosted on different servers.</p> +<p>In the Pull model, Nick has to poll Debbie&#8217;s server every time to check for updates from Debbie, and vice-versa.</p> +<p>In the Push model, the flow goes something like this:</p> +<ol> +<li> Debbie publishes a new entry on her server (Push);</li> +<li> Debbie&#8217;s server lets Nick&#8217;s server know that Debbie has published a new entry (Push);</li> +<li> Nick polls his own server to receive updates (Pull).</li> +</ol> +<p>Notice that the second step is a Push implemented in HTTP. Nick&#8217;s server didn&#8217;t have to poll Debbie&#8217;s server every time to check for updates.</p><p><a href="http://nick.iss.im/2009/10/22/http-and-the-push-model/">read more</a></p> +</description> + <pubDate>Thu, 22 Oct 2009 16:50:31 +0000</pubDate> +</item> +<item> + <title>Palantir: Pacific Northwest Drupal Summit</title> + <link>http://www.palantir.net/blog/pacific-northwest-drupal-summit</link> + <description><p>It's been a busy fall here in the Pacific Northwest. In the last two months the area has hosted no less than four Drupal events. </p> +<p>Things kicked off in late September with <a href="http://drupalcamp.northstudio.com/">DrupalCamp Victoria</a>. A couple weeks later was the Seattle Drupal Clinic, an event specifically focused at introducing new users to Drupal. Two weeks after that was <a href="http://drupalpdx.org/camp09/">DrupalCamp Portland</a>, and finally last week a group of Drupal luminaries gathered in Vancouver for the <a href="http://groups.drupal.org/node/24642">Drupal Contrib Code Sprint</a>, which resulted in <a href="http://drupal4hu.com/node/223">usable versions of Views and Coder for Drupal 7</a>! </p> +<p><img src="http://www.palantir.net/sites/default/files/pnw-summit-logo.png" alt="Pacific Northwest Drupal Summit" align="right" />Phew, that's a lot of Drupal! The best part is it's not over yet, Seattle will close off the Drupal season with the <a href="http://pnwdrupalsummit.org/">Pacific Northwest Drupal Summit</a> this coming weekend, October 24-25.</p> +<p><a href="http://www.palantir.net/blog/pacific-northwest-drupal-summit" target="_blank">read more</a></p></description> + <pubDate>Thu, 22 Oct 2009 16:44:15 +0000</pubDate> +</item> +<item> + <title>Stéphane Corlosquet: Produce and Consume Linked Data with Drupal!</title> + <link>http://openspring.net/blog/2009/10/22/produce-and-consume-linked-data-with-drupal</link> + <description><p><a href="http://openspring.net/sites/openspring.net/files/corl-etal-2009iswc_logo.png"><img style="float:right" src="http://openspring.net/sites/openspring.net/files/corl-etal-2009iswc_logo_thumb.png" alt="Drupal in the Linked Data Cloud" /></a><em>Produce and Consume Linked Data with Drupal!</em> is the title of the paper I will be presenting next week at the <a href="http://iswc2009.semanticweb.org/">8th International Semantic Web Conference (ISWC 2009)</a> in Washington, DC. I wrote it at the end of M.Sc. at <a href="http://www.deri.ie/">DERI</a>, in partnership with the <a href="http://hms.harvard.edu/">Harvard Medical School</a> and the <a href="http://www.massgeneral.org/">Massachusetts General Hospital</a> which is where I am <a href="http://openspring.net/blog/2009/09/19/one-way-ticket-to-boston">now working</a>.</p> +<p>It presents the approach for using Drupal (or any other CMS) as a Linked Data producer and consumer platform. Some part of this approach were used in the <a href="http://drupal.org/node/493030">RDF API</a> that Dries committed a few days ago to Drupal core. I have attached <a href="http://openspring.net/sites/openspring.net/files/corl-etal-2009iswc.pdf">full paper</a>, and here is the abstract:</p><p><a href="http://openspring.net/blog/2009/10/22/produce-and-consume-linked-data-with-drupal">read more</a></p> +</description> + <pubDate>Thu, 22 Oct 2009 13:03:26 +0000</pubDate> +</item> +<item> + <title>Dries Buytaert: Lucas Arts using Drupal</title> + <link>http://buytaert.net/lucas-arts-using-drupal</link> + <description>Lucas Arts, the video game company of George Lucas, launched a stunning <a href="http://drupal.org">Drupal</a> site for its upcoming MMORPG: <em>Star Wars, The Old Republic</em>. Check out the website at: <a href="http://www.swtor.com">http://www.swtor.com</a>. <em>The Force is strong with Drupal!</em> + +<div class="figure"> +<img src="http://buytaert.net/sites/buytaert.net/files/cache/drupal-star-wars-game-500x500.jpg" alt="Star wars game" style="border: 1px solid #ccc; padding: 4px;"/> + +</div> + +PS: in 2006, the <a href="http://lullabot.com">Lullabots</a> and myself visited Skywalker Ranch, the private workplace of George Lucas, to get <a href="http://buytaert.net/album/san-francisco-2006/light-saber-2">some lightsaber training</a>.</description> + <pubDate>Thu, 22 Oct 2009 11:40:31 +0000</pubDate> +</item> +<item> + <title>Janak Singh: Drupal Custom Pager navigation</title> + <link>http://janaksingh.com/blog/drupal-custom-pager-navigation-73</link> + <description><!-- google_ad_section_start --><p>For my portfolio site I wanted each image node (CCK + imagefield) to have a thumbnail strip of 10 or so images from the same category. Very simple stuff I thought. A quick search and I came across fantastic module called <a href="http://drupal.org/project/custom_pagers">Custom Pagers</a> by <a href="http://drupal.org/user/16496">Eaton</a>. This highly flexible module provides you a <b>Next</b> and <b>Previous</b> custom pager that you can display in your node pages. This was perfect for blog nodes but I wanted more control over the pager and I only wanted nodes to be pulled out from the same taxonomy term as the node being displayed.. fairly simple idea:</p> +<!-- google_ad_section_end --></description> + <pubDate>Thu, 22 Oct 2009 11:13:17 +0000</pubDate> +</item> +<item> + <title>Ryan Szrama: Ubercart 2.0 and the Ubercore Initiative</title> + <link>http://www.bywombats.com/blog/10-22-2009/ubercart-20-and-ubercore-initiative</link> + <description><p>With high spirits and much excitement for the future, Lyle and I polished up and released <a href="http://drupal.org/node/610966">Ubercart 2.0</a> today. Thanks to all those who took notice, and an even bigger thanks to the dozens of contributors who made the release a reality.</p> +<p>Features of the release should come as no surprise, as most people have been using Ubercart 2.x for some time based on the project's <a href="http://drupal.org/project/usage/ubercart">usage statistics</a> and personal experience. In the final days, we did iron out issues related to file downloads, role promotions, product kits, and Views integration. We also paved the way for smoother European use in conjunction with the <a href="http://drupal.org/project/uc_vat">UC2 VAT</a> project.</p> +<p>For those that are interested, continue reading for my reflections on the state of the Ubercart development process and code, including a community effort to realign both of these things on Drupal 7 with the <a href="http://d7uc.org">Drupal 7 Ubercore Initiative</a>.</p> +<p>The teaser... Ubercart, D7, Small core influence -> Ubercore (or, <a href="http://d7uc.org">d7uc</a>).</p> + <p><a href="http://www.bywombats.com/blog/10-22-2009/ubercart-20-and-ubercore-initiative" target="_blank">read more</a></p></description> + <pubDate>Thu, 22 Oct 2009 04:38:38 +0000</pubDate> +</item> +<item> + <title>Lullabot: Drupal Voices 66: Jimmy Berry on the Drupal Test Framework</title> + <link>http://feedproxy.google.com/~r/lullabot-all/~3/Nvm2rEBEhN4/drupal-voices-66-jimmy-berry-drupal-test-framework</link> + <description><!--paging_filter--><p><a href="http://boombatower.com/">Jimmy Berry</a> (aka <a href="http://drupal.org/user/214218">boombatower</a>) is the Drupal 7 Testing Subsystem Maintainer and maintainer of <a href="http://testing.drupal.org">testing.drupal.org.</a> Testing has become an integral part of the core Drupal development process as Drupal 7 has adopted a test-driven development model, which <a href="http://buytaert.net/drupal-7-testing-status-update-and-next-steps">Dries explains here.</a> </p> +<p>So Jimmy has picked up the testing torch for Drupal, and talks about his involvement with the <a href="http://drupal.org/project/simpletest">SimpleTest framework</a> and helping getting it into Drupal core, how that's changed the core development process, and what it could mean if also applied to contributed modules as well.</p> +<p><a href="http://www.lullabot.com/audio/download/635/DrupalVoices066.mp3">Download Audio</a></p></description> + <pubDate>Thu, 22 Oct 2009 03:46:07 +0000</pubDate> +</item> +<item> + <title>Dries Buytaert: Robbie Williams using Drupal</title> + <link>http://buytaert.net/robbie-williams-using-drupal</link> + <description><p>A couple of weeks ago, Robbie Williams made <a href="http://www.youtube.com/watch?v=FTJfNP1VEnw">his comeback</a> on +British television music talent show The X Factor, where he performed his new single "Bodies" for the first time live.</p> + +<p>With his comeback also comes a website refresh using <a href="http://drupal.org">Drupal</a>: see <a href="http://robbiewilliams.com">http://robbiewilliams.com</a>. The site was developed by an Acquia partner based in the UK.</p> + + +<div class="figure"> +<img src="http://buytaert.net/sites/buytaert.net/files/cache/drupal-robbie-williams-500x500.jpg" alt="Robbie williams" style="border: 1px solid #ccc; padding: 4px;"/> + +</div> + +<object width="500" height="392"><param name="movie" value="http://www.youtube.com/v/Q5uKa1bDtsk&hl=en&fs=1&"></param><param name="allowFullScreen" value="true"></param><param name="allowscriptaccess" value="always"></param><embed src="http://www.youtube.com/v/Q5uKa1bDtsk&hl=en&fs=1&" type="application/x-shockwave-flash" allowscriptaccess="always" allowfullscreen="true" width="500" height="392"></embed></object></description> + <pubDate>Thu, 22 Oct 2009 02:01:49 +0000</pubDate> +</item> +<item> + <title>Growing Venture Solutions: Introducing Token Starterkit - Simple Introduction to Creating your own Drupal Tokens</title> + <link>http://growingventuresolutions.com/blog/introducing-token-starterkit-simple-introduction-creating-your-own-drupal-tokens</link> + <description><p>There seems to be a new pattern emerging in Drupal and I want to let you know that the <a href="http://drupal.org/project/token">Token</a> module has joined the bandwagon with a "Token Starter Kit"</p> +<h3>History of the Starter Kit in Drupal: Zen Theming</h3> +<p>When the Zen project started it's goal was to be a really solid base HTML theme with tons of comments in the templates so that a new themer could take it, modify it, and end up with a great theme. Unfortunately, that second step of modifying it meant that people ran into all sorts of support issues that were hard to debug and they were in trouble when a new version of Zen came out - they weren't really running Zen any more.</p> +<h3>How to use the Token Starter Kit</h3> +<p>The Token Starter Kit is meant to be similarly easy for folks to use. The idea is that if you just open up the token module itself and start adding tokens then you are "hacking a contrib" (modifying it) and you will have to remember to make those changes again when you upgrade. Bad news. It's also not particularly simple to understand how the module works (it's got includes, and hooks, oh my!).</p><p><a href="http://growingventuresolutions.com/blog/introducing-token-starterkit-simple-introduction-creating-your-own-drupal-tokens">read more</a></p> +</description> + <pubDate>Wed, 21 Oct 2009 23:16:12 +0000</pubDate> +</item> +<item> + <title>Alldrupalthemes: Does spam thrive during economic decline?</title> + <link>http://www.alldrupalthemes.com/drupal-blog/does-spam-thrive-during-economic-decline</link> + <description><p>Are laid off IT workers discovering that sending spam is easier than getting a job these days? It sure seems that way, even with mollom running on all forms around a hundred spam comments get through every week, and they seem to get more clever every time.</p> +<p>I just found the following comment below my review of the <em>Drupal 6 Javascript and jQuery</em> book:</p> +<p><strong><em>Submitted by san diego real estate (not verified) on Wed, 10/21/2009 - 18:55.</em><br /> +The only reason why I like this book is that this book developers deep into the usage of jQuery in themes and modules and there is interesting stuff in there for developers of any experience.</strong></p> +<p>I can understand mollom didn't get that message because even I thought it was a real comment. I was much surprised to</p> +<p><a href="http://www.alldrupalthemes.com/drupal-blog/does-spam-thrive-during-economic-decline" target="_blank">read more</a></p></description> + <pubDate>Wed, 21 Oct 2009 22:53:39 +0000</pubDate> +</item> +<item> + <title>Tag1 Consulting: Tag1 Now Hiring Interns</title> + <link>http://tag1consulting.com/blog/tag1-now-hiring-interns</link> + <description><p>At the beginning of 2009, I was hired by Tag1 Consulting as Jeremy Andrews' full time partner. A decidedly questionable decision on his part, but a great change for me! I used to work at the Open Source Lab at Oregon State University and I am currently the sysadmin for drupal.org. Working at the OSL and drupal.org spoiled me, I'll be completely honest about that. I got used to working with interesting new technologies and consistently pushing the limits of my knowledge.</p> +<p><a href="http://tag1consulting.com/blog/tag1-now-hiring-interns">read more</a></p></description> + <pubDate>Wed, 21 Oct 2009 21:52:02 +0000</pubDate> +</item> +<item> + <title>Greg Holsclaw: Hook on Drush for Windows</title> + <link>http://www.tech-wanderings.com/drush-for-windows</link> + <description><p>So I have heard of <a href="http://drupal.org/project/drush">Drush</a> for years now, saw my first demo at the Boston DrupalCon but since I do all my dev work on a Windows machine I didn't catch the Drush wave (I kept hearing it was *nix only).</p> +<p>It has always been in the back of my mind to keep looking back into Drush, but somehow I missed the major 2.0 update in June and that it works on Windows now. When I saw <a href="http://morten.dk/blog/got-crush-drush">Morton's Mac Drush post</a> and revisited my Windows issue, and now I am a convert.</p> +<p>Already there is an <a href="http://drupal.org/node/594744">install guide</a> written two week ago that I have verified works perfectly for my Vista Business 64 bit machine. Drush is up and running on my dev system now and I am already addicted.</p> +<p><a href="http://www.tech-wanderings.com/drush-for-windows" target="_blank">read more</a></p></description> + <pubDate>Wed, 21 Oct 2009 20:00:34 +0000</pubDate> +</item> +<item> + <title>Geoff Hankerson: Installing Aegir Hosting System on OSX 10.6 with MAMP</title> + <link>http://geoffhankerson.com/node/109</link> + <description><p>Cross posted at <a href="http://groups.drupal.org/node/30270" title="http://groups.drupal.org/node/30270">http://groups.drupal.org/node/30270</a>.</p> +<p>Aegir install on OSX Snow Leopard</p> +<p>Aegir install instructions are fantastic if you run Debian/Ubuntu Linux. Some of the steps for OS X are a quite different</p> +<p><a href="http://geoffhankerson.com/node/109" target="_blank">read more</a></p></description> + <pubDate>Wed, 21 Oct 2009 19:01:59 +0000</pubDate> +</item> +<item> + <title>Development Seed: Announcing Managing News: A Pluggable News + Data Aggregator</title> + <link>http://developmentseed.org/blog/2009/oct/21/announcing-managing-news-pluggable-news-data-aggregator</link> + <description><div class="field field-type-text field-field-subtitle"> + <div class="field-items"> + <div class="field-item odd"> + <p>From a daily news reader, to a platform for election monitoring in Afghanistan or swine flu preparedness in the United States</p> </div> + </div> +</div> +<div class='node-body'><p>Managing News is a pluggable, open source news and data aggregator with visualization and workflow tools that's highly customizable and extensible. The code is now in open beta and is available for download on <a href="http://www.managingnews.com/download">www.managingnews.com/download</a>.</p> + +<p><img src="http://farm3.static.flickr.com/2463/4031496689_0dd24a5705.jpg" alt="http://managingnews.com" /></p><p><a href="http://developmentseed.org/blog/2009/oct/21/announcing-managing-news-pluggable-news-data-aggregator">read more</a></p> +</description> + <pubDate>Wed, 21 Oct 2009 17:14:57 +0000</pubDate> +</item> +<item> + <title>Good Old Drupal: Co-Maintainers Wanted!</title> + <link>http://goodold.se/blog/tech/co-maintainer-wanted</link> + <description><p>The list of modules that I maintain has become quite long, and in the beginning of next year I'll have a little daughter (if the nurse guessed right on the gender). So the time that I have for being a good maintainer will be very limited.</p> +<p>If you feel that you'd like to help maintain any of the following modules, I would be very grateful!</p> +<ul> +<li><a href="http://drupal.org/project/cobalt">Cobalt</a> </li> +<li><a href="http://drupal.org/project/nodeformcols">Node form columns</a></li> +<li><a href="http://drupal.org/project/oauth_common">OAuth Common</a></li> +<li><a href="http://drupal.org/project/services_oauth">Services OAuth</a></li> +<li><a href="http://drupal.org/project/simple_geo">Simple Geo</a></li> +<li><a href="http://drupal.org/project/jsonrpc_server">JSONRPC Server</a></li> +<li><a href="http://drupal.org/project/query_builder">Query builder</a></li> +</ul> +<p><strong>Modules not on DO</strong></p> +<ul> +<li><a href="http://github.com/hugowetterberg/services_oop">Services OOP</a></li> +<li><a href="http://github.com/hugowetterberg/cssdry">CSS DRY</a></li> +</ul> +<p>Experience of using <strong>git</strong>, or the willingness to learn, is kind of a requirement, as all my development is done with git. The alternative is a patch-based workflow.</p></description> + <pubDate>Wed, 21 Oct 2009 06:36:30 +0000</pubDate> +</item> +<item> + <title>Lullabot: Drupal Voices 65: Konstantin Kafer on Optimizing Javascript and CSS</title> + <link>http://feedproxy.google.com/~r/lullabot-all/~3/EcYAQCa5xyE/drupal-voices-65-konstantin-kafer-optimizing-javascript-and-css</link> + <description><!--paging_filter--><p><a href="http://kkaefer.com/">Konstantin Käfer</a> (aka <a href="http://drupal.org/user/14572">kkafer</a>) gives an overview of his Drupalcon Paris talk on optimizing the front-end Javascript and CSS -- including some <a href="http://developer.yahoo.com/yslow/">YSlow</a> tips.</p> +<p>Konstantin also talks a bit about his <a href="http://drupal.org/project/sf_cache">Support File Cache</a> module that allows additional front-end optimizations by allowing you to control the bundling of CSS and Javascript files.</p> +<p>He also talks a bit about the <a href="http://frontenddrupal.com/">Front-End Drupal</a> that he co-wrote with <a href="http://www.lullabot.com/drupal-voices/drupal-voices-60-emma-jane-hogbin-theming-and-bazaar-version-control">Emma Jane Hogbin.</a></p> +<p>Finally, Konstantin talks a bit about some of his favorite changes in Drupal 7.</p> +<p><a href="http://www.lullabot.com/audio/download/633/DrupalVoices065.mp3">Download Audio</a></p></description> + <pubDate>Wed, 21 Oct 2009 03:09:59 +0000</pubDate> +</item> +<item> + <title>Morten.dk - The King of Denmark: got a crush on drush</title> + <link>http://morten.dk/blog/got-crush-drush</link> + <description><div class="fieldgroup group-image"> + + + <div class="content"> +<div class="field-image-default"> + + + + + <img src="http://morten.dk/sites/morten.dk/files/imagecache/20_20_crop/drushlove.jpg" alt="" title="Drupal shell geekyness" class="imagecache imagecache-20_20_crop imagecache-default imagecache-20_20_crop_default" width="20" height="20" /> + +</div> +</div> + +</div> +<p>How I got drush to work on my macosx with mamp pro, and still my illustrator &amp; photoshop works fine, how to do magick stuff with the terminal with out <a href="/sites/morten.dk/files/images/argh.jpg" class="fancybox">deleting to much</a>, and learning not to use my <a href="http://www.wacom.com/bamboo/bamboo_fun.php">pen</a> to navigate my desktop...</p></description> + <pubDate>Wed, 21 Oct 2009 01:56:57 +0000</pubDate> +</item> +<item> + <title>Affinity Bridge: Drupal7 Contrib Module Upgrade Sprint</title> + <link>http://affinitybridge.com/blog/drupal7-contrib-module-upgrade-sprint</link> + <description><p>This past weekend was the <a href="http://groups.drupal.org/node/24642">Drupal7 Contrib Module Upgrade Sprint</a> that <a href="http://drupal.org/user/9446">K&aacute;roly N&eacute;gyesi</a> (aka chx) organized at the <a title="Now Public" href="http://www.nowpublic.com/">NowPublic</a> offices in Vancouver. I spent a good part of Saturday there, helped out with coaching the one brave beginner who turned up to learn some of the tools for helping out in the community. Otherwise, after a bit of a rough start, the devs all hunkered down and made some Drupal magic, upgrading super important things like Views, Panels, database stuff, and various other bits and pieces of modules and themes.</p> +<p><a title="D7 contrib sprint by arianek, on Flickr" href="http://www.flickr.com/photos/arianek/4023847590/"></a></p> +<p style="text-align: center;"><img src="http://farm3.static.flickr.com/2629/4023847590_98f25f162f.jpg" alt="D7 contrib sprint" width="500" height="375" /></p> +<p> &lt;!--break--> +</p><p><a href="http://affinitybridge.com/blog/drupal7-contrib-module-upgrade-sprint">read more</a></p> +</description> + <pubDate>Tue, 20 Oct 2009 23:43:18 +0000</pubDate> +</item> +<item> + <title>Do it With Drupal: Speaker Spotlight: Earl Miles</title> + <link>http://feedproxy.google.com/~r/DoItWithDrupal/~3/Thij4pd2cBE/speaker-spotlight-earl-miles</link> + <description><p><img src="http://www.doitwithdrupal.com/files/imagecache/120square/biopics/earl-headshot2.jpg" alt="earl-headshot2" title="earl-headshot2" class="image-right" height="120" width="120" />We are excited to announce that <a href="http://www.angrydonuts.com/">Earl Miles</a> will be returning to <a href="http://www.doitwithdrupal.com/">Do It With Drupal</a>! It is no exaggeration to say that Earl Miles single-handedly revolutionized the <a href="http://drupal.org/">Drupal</a> community when he released the <a href="http://drupal.org/project/views">Views</a> module late in 2005.</p> +<p><a href="http://www.doitwithdrupal.com/blog/speaker-spotlight-earl-miles" target="_blank">read more</a></p><p><a href="http://feedproxy.google.com/~r/DoItWithDrupal/~3/Thij4pd2cBE/speaker-spotlight-earl-miles">read more</a></p> +</description> + <pubDate>Tue, 20 Oct 2009 22:06:32 +0000</pubDate> +</item> +</channel> +</rss> diff --git a/sites/all/modules/feeds/tests/feeds/earthquake-georss.atom b/sites/all/modules/feeds/tests/feeds/earthquake-georss.atom new file mode 100644 index 0000000000000000000000000000000000000000..181755fe839c6f030edde9c6aae663b2308f13dc --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds/earthquake-georss.atom @@ -0,0 +1,36 @@ +<?xml version="1.0"?> +<feed xmlns="http://www.w3.org/2005/Atom" xmlns:georss="http://www.georss.org/georss"> + <updated>2010-09-07T21:45:39Z</updated> + <title>USGS M2.5+ Earthquakes</title> + <subtitle>Real-time, worldwide earthquake list for the past day</subtitle> + <link rel="self" href="http://earthquake.usgs.gov/earthquakes/catalogs/1day-M2.5.xml"/> + <link href="http://earthquake.usgs.gov/earthquakes/"/> + <author><name>U.S. Geological Survey</name></author> + <id>http://earthquake.usgs.gov/</id> + <icon>/favicon.ico</icon> + <entry><id>urn:earthquake-usgs-gov:ak:10076864</id><title>M 2.6, Central Alaska</title><updated>2010-09-07T21:08:45Z</updated><link rel="alternate" type="text/html" href="http://earthquake.usgs.gov/earthquakes/recenteqsww/Quakes/ak10076864.php"/><summary type="html"><![CDATA[<img src="http://earthquake.usgs.gov/images/globes/65_-150.jpg" alt="64.858°N 150.864°W" align="left" hspace="20" /><p>Tuesday, September 7, 2010 21:08:45 UTC<br>Tuesday, September 7, 2010 01:08:45 PM at epicenter</p><p><strong>Depth</strong>: 11.20 km (6.96 mi)</p>]]></summary><georss:point>64.8581 -150.8643</georss:point><georss:elev>-11200</georss:elev><category label="Age" term="Past hour"/></entry> + <entry><id>urn:earthquake-usgs-gov:us:2010axbz</id><title>M 4.9, southern Qinghai, China</title><updated>2010-09-07T20:51:02Z</updated><link rel="alternate" type="text/html" href="http://earthquake.usgs.gov/earthquakes/recenteqsww/Quakes/us2010axbz.php"/><summary type="html"><![CDATA[<img src="http://earthquake.usgs.gov/images/globes/35_95.jpg" alt="33.329°N 96.332°E" align="left" hspace="20" /><p>Tuesday, September 7, 2010 20:51:02 UTC<br>Wednesday, September 8, 2010 04:51:02 AM at epicenter</p><p><strong>Depth</strong>: 47.50 km (29.52 mi)</p>]]></summary><georss:point>33.3289 96.3324</georss:point><georss:elev>-47500</georss:elev><category label="Age" term="Past hour"/></entry> + <entry><id>urn:earthquake-usgs-gov:us:2010axbr</id><title>M 5.2, southern East Pacific Rise</title><updated>2010-09-07T19:54:29Z</updated><link rel="alternate" type="text/html" href="http://earthquake.usgs.gov/earthquakes/recenteqsww/Quakes/us2010axbr.php"/><link rel="related" type="application/cap+xml" href="http://earthquake.usgs.gov/earthquakes/catalogs/cap/us2010axbr" /><summary type="html"><![CDATA[<img src="http://earthquake.usgs.gov/images/globes/-55_-120.jpg" alt="53.198°S 118.068°W" align="left" hspace="20" /><p>Tuesday, September 7, 2010 19:54:29 UTC<br>Tuesday, September 7, 2010 11:54:29 AM at epicenter</p><p><strong>Depth</strong>: 15.50 km (9.63 mi)</p>]]></summary><georss:point>-53.1979 -118.0676</georss:point><georss:elev>-15500</georss:elev><category label="Age" term="Past day"/></entry> + <entry><id>urn:earthquake-usgs-gov:us:2010axbp</id><title>M 5.0, South Island of New Zealand</title><updated>2010-09-07T19:49:57Z</updated><link rel="alternate" type="text/html" href="http://earthquake.usgs.gov/earthquakes/recenteqsww/Quakes/us2010axbp.php"/><summary type="html"><![CDATA[<img src="http://earthquake.usgs.gov/images/globes/-45_175.jpg" alt="43.437°S 172.590°E" align="left" hspace="20" /><p>Tuesday, September 7, 2010 19:49:57 UTC<br>Wednesday, September 8, 2010 07:49:57 AM at epicenter</p><p><strong>Depth</strong>: 1.00 km (0.62 mi)</p>]]></summary><georss:point>-43.4371 172.5902</georss:point><georss:elev>-1000</georss:elev><category label="Age" term="Past day"/></entry> + <entry><id>urn:earthquake-usgs-gov:ak:10076859</id><title>M 3.1, Andreanof Islands, Aleutian Islands, Alaska</title><updated>2010-09-07T19:20:05Z</updated><link rel="alternate" type="text/html" href="http://earthquake.usgs.gov/earthquakes/recenteqsww/Quakes/ak10076859.php"/><link rel="related" type="application/cap+xml" href="http://earthquake.usgs.gov/earthquakes/catalogs/cap/ak10076859" /><summary type="html"><![CDATA[<img src="http://earthquake.usgs.gov/images/globes/50_-175.jpg" alt="51.526°N 175.798°W" align="left" hspace="20" /><p>Tuesday, September 7, 2010 19:20:05 UTC<br>Tuesday, September 7, 2010 10:20:05 AM at epicenter</p><p><strong>Depth</strong>: 22.20 km (13.79 mi)</p>]]></summary><georss:point>51.5259 -175.7979</georss:point><georss:elev>-22200</georss:elev><category label="Age" term="Past day"/></entry> + <entry><id>urn:earthquake-usgs-gov:ci:10793957</id><title>M 2.7, Southern California</title><updated>2010-09-07T18:50:42Z</updated><link rel="alternate" type="text/html" href="http://earthquake.usgs.gov/earthquakes/recenteqsww/Quakes/ci10793957.php"/><summary type="html"><![CDATA[<img src="http://earthquake.usgs.gov/images/globes/35_-115.jpg" alt="35.717°N 116.960°W" align="left" hspace="20" /><p>Tuesday, September 7, 2010 18:50:42 UTC<br>Tuesday, September 7, 2010 11:50:42 AM at epicenter</p><p><strong>Depth</strong>: 7.80 km (4.85 mi)</p>]]></summary><georss:point>35.7170 -116.9597</georss:point><georss:elev>-7800</georss:elev><category label="Age" term="Past day"/></entry> + <entry><id>urn:earthquake-usgs-gov:ci:10793909</id><title>M 3.5, Southern California</title><updated>2010-09-07T17:29:13Z</updated><link rel="alternate" type="text/html" href="http://earthquake.usgs.gov/earthquakes/recenteqsww/Quakes/ci10793909.php"/><link rel="related" type="application/cap+xml" href="http://earthquake.usgs.gov/earthquakes/catalogs/cap/ci10793909" /><summary type="html"><![CDATA[<img src="http://earthquake.usgs.gov/images/globes/35_-115.jpg" alt="35.727°N 116.957°W" align="left" hspace="20" /><p>Tuesday, September 7, 2010 17:29:13 UTC<br>Tuesday, September 7, 2010 10:29:13 AM at epicenter</p><p><strong>Depth</strong>: 4.50 km (2.80 mi)</p>]]></summary><georss:point>35.7273 -116.9567</georss:point><georss:elev>-4500</georss:elev><category label="Age" term="Past day"/></entry> + <entry><id>urn:earthquake-usgs-gov:ak:10076853</id><title>M 3.1, Andreanof Islands, Aleutian Islands, Alaska</title><updated>2010-09-07T17:08:19Z</updated><link rel="alternate" type="text/html" href="http://earthquake.usgs.gov/earthquakes/recenteqsww/Quakes/ak10076853.php"/><link rel="related" type="application/cap+xml" href="http://earthquake.usgs.gov/earthquakes/catalogs/cap/ak10076853" /><summary type="html"><![CDATA[<img src="http://earthquake.usgs.gov/images/globes/50_-175.jpg" alt="51.090°N 176.131°W" align="left" hspace="20" /><p>Tuesday, September 7, 2010 17:08:19 UTC<br>Tuesday, September 7, 2010 08:08:19 AM at epicenter</p><p><strong>Depth</strong>: 16.50 km (10.25 mi)</p>]]></summary><georss:point>51.0899 -176.1314</georss:point><georss:elev>-16500</georss:elev><category label="Age" term="Past day"/></entry> + <entry><id>urn:earthquake-usgs-gov:us:2010axa9</id><title>M 6.3, Fiji region</title><updated>2010-09-07T16:13:32Z</updated><link rel="alternate" type="text/html" href="http://earthquake.usgs.gov/earthquakes/recenteqsww/Quakes/us2010axa9.php"/><summary type="html"><![CDATA[<img src="http://earthquake.usgs.gov/images/globes/-15_-180.jpg" alt="15.869°S 179.261°W" align="left" hspace="20" /><p>Tuesday, September 7, 2010 16:13:32 UTC<br>Wednesday, September 8, 2010 04:13:32 AM at epicenter</p><p><strong>Depth</strong>: 10.00 km (6.21 mi)</p>]]></summary><georss:point>-15.8694 -179.2611</georss:point><georss:elev>-10000</georss:elev><category label="Age" term="Past day"/></entry> + <entry><id>urn:earthquake-usgs-gov:us:2010axa7</id><title>M 5.3, Kyrgyzstan</title><updated>2010-09-07T15:41:42Z</updated><link rel="alternate" type="text/html" href="http://earthquake.usgs.gov/earthquakes/recenteqsww/Quakes/us2010axa7.php"/><link rel="related" type="application/cap+xml" href="http://earthquake.usgs.gov/earthquakes/catalogs/cap/us2010axa7" /><summary type="html"><![CDATA[<img src="http://earthquake.usgs.gov/images/globes/40_75.jpg" alt="39.476°N 73.825°E" align="left" hspace="20" /><p>Tuesday, September 7, 2010 15:41:42 UTC<br>Tuesday, September 7, 2010 09:41:42 PM at epicenter</p><p><strong>Depth</strong>: 39.70 km (24.67 mi)</p>]]></summary><georss:point>39.4759 73.8254</georss:point><georss:elev>-39700</georss:elev><category label="Age" term="Past day"/></entry> + <entry><id>urn:earthquake-usgs-gov:ci:10793837</id><title>M 2.7, Southern California</title><updated>2010-09-07T13:07:21Z</updated><link rel="alternate" type="text/html" href="http://earthquake.usgs.gov/earthquakes/recenteqsww/Quakes/ci10793837.php"/><summary type="html"><![CDATA[<img src="http://earthquake.usgs.gov/images/globes/35_-115.jpg" alt="35.725°N 116.963°W" align="left" hspace="20" /><p>Tuesday, September 7, 2010 13:07:21 UTC<br>Tuesday, September 7, 2010 06:07:21 AM at epicenter</p><p><strong>Depth</strong>: 3.60 km (2.24 mi)</p>]]></summary><georss:point>35.7245 -116.9630</georss:point><georss:elev>-3600</georss:elev><category label="Age" term="Past day"/></entry> + <entry><id>urn:earthquake-usgs-gov:nc:71451855</id><title>M 2.5, Northern California</title><updated>2010-09-07T13:06:56Z</updated><link rel="alternate" type="text/html" href="http://earthquake.usgs.gov/earthquakes/recenteqsww/Quakes/nc71451855.php"/><link rel="related" type="application/cap+xml" href="http://earthquake.usgs.gov/earthquakes/catalogs/cap/nc71451855" /><summary type="html"><![CDATA[<img src="http://earthquake.usgs.gov/images/globes/40_-120.jpg" alt="39.210°N 120.067°W" align="left" hspace="20" /><p>Tuesday, September 7, 2010 13:06:56 UTC<br>Tuesday, September 7, 2010 06:06:56 AM at epicenter</p><p><strong>Depth</strong>: 8.20 km (5.10 mi)</p>]]></summary><georss:point>39.2102 -120.0667</georss:point><georss:elev>-8200</georss:elev><category label="Age" term="Past day"/></entry> + <entry><id>urn:earthquake-usgs-gov:us:2010axax</id><title>M 5.4, Fiji region</title><updated>2010-09-07T12:49:01Z</updated><link rel="alternate" type="text/html" href="http://earthquake.usgs.gov/earthquakes/recenteqsww/Quakes/us2010axax.php"/><link rel="related" type="application/cap+xml" href="http://earthquake.usgs.gov/earthquakes/catalogs/cap/us2010axax" /><summary type="html"><![CDATA[<img src="http://earthquake.usgs.gov/images/globes/-15_-175.jpg" alt="14.361°S 176.241°W" align="left" hspace="20" /><p>Tuesday, September 7, 2010 12:49:01 UTC<br>Wednesday, September 8, 2010 12:49:01 AM at epicenter</p><p><strong>Depth</strong>: 35.50 km (22.06 mi)</p>]]></summary><georss:point>-14.3605 -176.2406</georss:point><georss:elev>-35500</georss:elev><category label="Age" term="Past day"/></entry> + <entry><id>urn:earthquake-usgs-gov:us:2010axat</id><title>M 5.0, Kuril Islands</title><updated>2010-09-07T11:30:52Z</updated><link rel="alternate" type="text/html" href="http://earthquake.usgs.gov/earthquakes/recenteqsww/Quakes/us2010axat.php"/><link rel="related" type="application/cap+xml" href="http://earthquake.usgs.gov/earthquakes/catalogs/cap/us2010axat" /><summary type="html"><![CDATA[<img src="http://earthquake.usgs.gov/images/globes/45_150.jpg" alt="45.858°N 151.311°E" align="left" hspace="20" /><p>Tuesday, September 7, 2010 11:30:52 UTC<br>Tuesday, September 7, 2010 11:30:52 PM at epicenter</p><p><strong>Depth</strong>: 30.30 km (18.83 mi)</p>]]></summary><georss:point>45.8582 151.3105</georss:point><georss:elev>-30300</georss:elev><category label="Age" term="Past day"/></entry> + <entry><id>urn:earthquake-usgs-gov:mb:25757</id><title>M 2.7, western Montana</title><updated>2010-09-07T10:08:26Z</updated><link rel="alternate" type="text/html" href="http://earthquake.usgs.gov/earthquakes/recenteqsww/Quakes/mb25757.php"/><summary type="html"><![CDATA[<img src="http://earthquake.usgs.gov/images/globes/45_-110.jpg" alt="44.951°N 111.742°W" align="left" hspace="20" /><p>Tuesday, September 7, 2010 10:08:26 UTC<br>Tuesday, September 7, 2010 04:08:26 AM at epicenter</p><p><strong>Depth</strong>: 5.70 km (3.54 mi)</p>]]></summary><georss:point>44.9508 -111.7423</georss:point><georss:elev>-5700</georss:elev><category label="Age" term="Past day"/></entry> + <entry><id>urn:earthquake-usgs-gov:ak:10076821</id><title>M 2.7, Andreanof Islands, Aleutian Islands, Alaska</title><updated>2010-09-07T08:40:35Z</updated><link rel="alternate" type="text/html" href="http://earthquake.usgs.gov/earthquakes/recenteqsww/Quakes/ak10076821.php"/><link rel="related" type="application/cap+xml" href="http://earthquake.usgs.gov/earthquakes/catalogs/cap/ak10076821" /><summary type="html"><![CDATA[<img src="http://earthquake.usgs.gov/images/globes/50_-175.jpg" alt="51.201°N 176.194°W" align="left" hspace="20" /><p>Tuesday, September 7, 2010 08:40:35 UTC<br>Monday, September 6, 2010 11:40:35 PM at epicenter</p><p><strong>Depth</strong>: 20.10 km (12.49 mi)</p>]]></summary><georss:point>51.2010 -176.1935</georss:point><georss:elev>-20100</georss:elev><category label="Age" term="Past day"/></entry> + <entry><id>urn:earthquake-usgs-gov:us:2010axas</id><title>M 4.9, southwest of Sumatra, Indonesia</title><updated>2010-09-07T07:22:13Z</updated><link rel="alternate" type="text/html" href="http://earthquake.usgs.gov/earthquakes/recenteqsww/Quakes/us2010axas.php"/><summary type="html"><![CDATA[<img src="http://earthquake.usgs.gov/images/globes/-5_105.jpg" alt="7.128°S 103.263°E" align="left" hspace="20" /><p>Tuesday, September 7, 2010 07:22:13 UTC<br>Tuesday, September 7, 2010 02:22:13 PM at epicenter</p><p><strong>Depth</strong>: 35.00 km (21.75 mi)</p>]]></summary><georss:point>-7.1275 103.2631</georss:point><georss:elev>-35000</georss:elev><category label="Age" term="Past day"/></entry> + <entry><id>urn:earthquake-usgs-gov:nc:71451750</id><title>M 3.1, Central California</title><updated>2010-09-07T06:59:25Z</updated><link rel="alternate" type="text/html" href="http://earthquake.usgs.gov/earthquakes/recenteqsww/Quakes/nc71451750.php"/><link rel="related" type="application/cap+xml" href="http://earthquake.usgs.gov/earthquakes/catalogs/cap/nc71451750" /><summary type="html"><![CDATA[<img src="http://earthquake.usgs.gov/images/globes/35_-120.jpg" alt="36.561°N 121.068°W" align="left" hspace="20" /><p>Tuesday, September 7, 2010 06:59:25 UTC<br>Monday, September 6, 2010 11:59:25 PM at epicenter</p><p><strong>Depth</strong>: 8.40 km (5.22 mi)</p>]]></summary><georss:point>36.5605 -121.0677</georss:point><georss:elev>-8400</georss:elev><category label="Age" term="Past day"/></entry> + <entry><id>urn:earthquake-usgs-gov:ak:10076797</id><title>M 4.2, Kodiak Island region, Alaska</title><updated>2010-09-07T05:54:04Z</updated><link rel="alternate" type="text/html" href="http://earthquake.usgs.gov/earthquakes/recenteqsww/Quakes/ak10076797.php"/><link rel="related" type="application/cap+xml" href="http://earthquake.usgs.gov/earthquakes/catalogs/cap/ak10076797" /><summary type="html"><![CDATA[<img src="http://earthquake.usgs.gov/images/globes/55_-150.jpg" alt="56.980°N 151.666°W" align="left" hspace="20" /><p>Tuesday, September 7, 2010 05:54:04 UTC<br>Monday, September 6, 2010 09:54:04 PM at epicenter</p><p><strong>Depth</strong>: 25.00 km (15.53 mi)</p>]]></summary><georss:point>56.9797 -151.6661</georss:point><georss:elev>-25000</georss:elev><category label="Age" term="Past day"/></entry> + <entry><id>urn:earthquake-usgs-gov:ak:10076786</id><title>M 3.4, Andreanof Islands, Aleutian Islands, Alaska</title><updated>2010-09-07T04:43:36Z</updated><link rel="alternate" type="text/html" href="http://earthquake.usgs.gov/earthquakes/recenteqsww/Quakes/ak10076786.php"/><link rel="related" type="application/cap+xml" href="http://earthquake.usgs.gov/earthquakes/catalogs/cap/ak10076786" /><summary type="html"><![CDATA[<img src="http://earthquake.usgs.gov/images/globes/50_-175.jpg" alt="51.098°N 176.164°W" align="left" hspace="20" /><p>Tuesday, September 7, 2010 04:43:36 UTC<br>Monday, September 6, 2010 07:43:36 PM at epicenter</p><p><strong>Depth</strong>: 16.90 km (10.50 mi)</p>]]></summary><georss:point>51.0975 -176.1635</georss:point><georss:elev>-16900</georss:elev><category label="Age" term="Past day"/></entry> + <entry><id>urn:earthquake-usgs-gov:ak:10076776</id><title>M 3.6, Andreanof Islands, Aleutian Islands, Alaska</title><updated>2010-09-07T03:43:43Z</updated><link rel="alternate" type="text/html" href="http://earthquake.usgs.gov/earthquakes/recenteqsww/Quakes/ak10076776.php"/><summary type="html"><![CDATA[<img src="http://earthquake.usgs.gov/images/globes/50_-175.jpg" alt="51.471°N 176.667°W" align="left" hspace="20" /><p>Tuesday, September 7, 2010 03:43:43 UTC<br>Monday, September 6, 2010 06:43:43 PM at epicenter</p><p><strong>Depth</strong>: 39.00 km (24.23 mi)</p>]]></summary><georss:point>51.4706 -176.6674</georss:point><georss:elev>-39000</georss:elev><category label="Age" term="Past day"/></entry> + <entry><id>urn:earthquake-usgs-gov:us:2010axaf</id><title>M 5.3, southern Iran</title><updated>2010-09-07T02:11:07Z</updated><link rel="alternate" type="text/html" href="http://earthquake.usgs.gov/earthquakes/recenteqsww/Quakes/us2010axaf.php"/><link rel="related" type="application/cap+xml" href="http://earthquake.usgs.gov/earthquakes/catalogs/cap/us2010axaf" /><summary type="html"><![CDATA[<img src="http://earthquake.usgs.gov/images/globes/25_55.jpg" alt="27.147°N 54.588°E" align="left" hspace="20" /><p>Tuesday, September 7, 2010 02:11:07 UTC<br>Tuesday, September 7, 2010 05:41:07 AM at epicenter</p><p><strong>Depth</strong>: 27.90 km (17.34 mi)</p>]]></summary><georss:point>27.1465 54.5877</georss:point><georss:elev>-27900</georss:elev><category label="Age" term="Past day"/></entry> + <entry><id>urn:earthquake-usgs-gov:us:2010axad</id><title>M 4.9, Fiji region</title><updated>2010-09-07T01:42:39Z</updated><link rel="alternate" type="text/html" href="http://earthquake.usgs.gov/earthquakes/recenteqsww/Quakes/us2010axad.php"/><summary type="html"><![CDATA[<img src="http://earthquake.usgs.gov/images/globes/-20_-180.jpg" alt="19.657°S 177.699°W" align="left" hspace="20" /><p>Tuesday, September 7, 2010 01:42:39 UTC<br>Tuesday, September 7, 2010 01:42:39 PM at epicenter</p><p><strong>Depth</strong>: 390.40 km (242.58 mi)</p>]]></summary><georss:point>-19.6573 -177.6987</georss:point><georss:elev>-390400</georss:elev><category label="Age" term="Past day"/></entry> + <entry><id>urn:earthquake-usgs-gov:us:2010axab</id><title>M 5.8, southwest of Sumatra, Indonesia</title><updated>2010-09-07T00:57:26Z</updated><link rel="alternate" type="text/html" href="http://earthquake.usgs.gov/earthquakes/recenteqsww/Quakes/us2010axab.php"/><link rel="related" type="application/cap+xml" href="http://earthquake.usgs.gov/earthquakes/catalogs/cap/us2010axab" /><summary type="html"><![CDATA[<img src="http://earthquake.usgs.gov/images/globes/-5_105.jpg" alt="6.967°S 103.657°E" align="left" hspace="20" /><p>Tuesday, September 7, 2010 00:57:26 UTC<br>Tuesday, September 7, 2010 07:57:26 AM at epicenter</p><p><strong>Depth</strong>: 34.10 km (21.19 mi)</p>]]></summary><georss:point>-6.9665 103.6573</georss:point><georss:elev>-34100</georss:elev><category label="Age" term="Past day"/></entry> + <entry><id>urn:earthquake-usgs-gov:us:2010awby</id><title>M 5.2, North Island of New Zealand</title><updated>2010-09-06T22:48:33Z</updated><link rel="alternate" type="text/html" href="http://earthquake.usgs.gov/earthquakes/recenteqsww/Quakes/us2010awby.php"/><summary type="html"><![CDATA[<img src="http://earthquake.usgs.gov/images/globes/-40_175.jpg" alt="40.143°S 176.657°E" align="left" hspace="20" /><p>Monday, September 6, 2010 22:48:33 UTC<br>Tuesday, September 7, 2010 10:48:33 AM at epicenter</p><p><strong>Depth</strong>: 16.70 km (10.38 mi)</p>]]></summary><georss:point>-40.1430 176.6567</georss:point><georss:elev>-16700</georss:elev><category label="Age" term="Past day"/></entry> +</feed> diff --git a/sites/all/modules/feeds/tests/feeds/feed_without_guid.rss2 b/sites/all/modules/feeds/tests/feeds/feed_without_guid.rss2 new file mode 100644 index 0000000000000000000000000000000000000000..af4569f241ac199b868e7d25eb4467d98d62d02b --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds/feed_without_guid.rss2 @@ -0,0 +1,189 @@ +<rss version="2.0"> +<channel> + <generator>Rss generator</generator> + <pubDate>2009.10.20. 16:49:01</pubDate> + <title>Magyar Nemzet Online - Hírek</title> + <description>Magyar Nemzet Online - Hírek</description> + <link>http://www.mno.hu</link> + <language>HU</language> + <image> + <url>http://www.mno.hu/docfiles/rss/mno01.jpg</url> + <link>http://www.mno.hu</link> + <description>Magyar Nemzet Online - Hírek</description> + <title>Magyar Nemzet Online - Hírek</title> + <width>88</width> + <height>31</height> + </image> + <item> + <title> + Megint csak a balhé + Videó + </title> + <description> + Az egykori kiváló labdarúgó, Paul Gascoigne ezúttal sem futballtudása vagy esetleges edzői karrierjével került a címlapokra. A korábbi válogatott középpályás most egy snooker klubban okozott botrányt: lefejelte, majd bocsánatkérésként arcon csókolta az egyik kidobót. Utóbbi úriember meggondolatlanul cselekedett, mikor rászólt a sztárra, hogy tilos a dohányzás. + </description> + <pubDate> + 2009-10-20 16:49 +0200 + </pubDate> + <link> + http://www.mno.hu/portal/671000 + </link> + <category> + Online + </category> + </item> + <item> + <title> + Az uniós tejalap csak filléreket hoz a magyar termelőknek + </title> + <description> + A magyar tejtermelők véleménye szerint az unió által kvóta alapon kioszthatónak minősített 280 millió eurós tejalap nem igazán hathatós segítség, mivel az tejkvóta-kilogrammonként mindössze 50 fillér többletet jelenthet egy termelő számára – mondta az MTI megkeresésére kedden Bakos Erzsébet, a Tej Terméktanács szakmai koordinátora.<br /><a href="http://mno.hu/portal/670961"><strong><br /><span class="content_blog_lead" style="font-weight: bold"></span><strong><strong><strong><strong>•</strong></strong> Sürgősséggel döntenek a tejalapról</strong></strong></strong></a> + </description> + <pubDate> + 2009-10-20 16:48 +0200 + </pubDate> + <link> + http://www.mno.hu/portal/670998 + </link> + <category> + Online + </category> + </item> + <item> + <title> + Pluszban zárt a BUX + </title> + <description> + A Budapesti Értéktőzsde részvényindexe, a BUX 70,24 pontos, 0,33 százalékos emelkedéssel, 21.474,51 ponton zárt kedden. + </description> + <pubDate> + 2009-10-20 16:44 +0200 + </pubDate> + <link> + http://www.mno.hu/portal/671006 + </link> + <category> + Online + </category> + </item> + <item> + <title> + Bajnait fogadja a pápa + </title> + <description> + Bajnai Gordon miniszterelnök november 13-án a Vatikánba utazik, hogy találkozzon XVI. Benedek pápával – erősítette meg a nol.hu információját a kormányszóvivő. + </description> + <pubDate> + 2009-10-20 16:40 +0200 + </pubDate> + <link> + http://www.mno.hu/portal/670997 + </link> + <category> + Online + </category> + </item> + <item> + <title> + Élelmiszereink 11 százaléka „saját” + </title> + <description> + Egyre többet költünk élelmiszerre, és egyre kevesebb élelmiszert termelünk meg magunknak - derül ki a KSH 2000-2007 közötti éveket vizsgáló statisztikájából. Állati zsírt és cukrot kevesebbet fogyasztunk, gyorsétterembe viszont többet járunk. + </description> + <pubDate> + 2009-10-20 16:31 +0200 + </pubDate> + <link> + http://www.mno.hu/portal/670989 + </link> + <category> + Online + </category> + </item> + <item> + <title> + Leleplezte magát a Heves megyei szocialista közgyűlési elnök + </title> + <description> + Leleplezte magát Sós Tamás, a hevesi megyegyűlés elnöke, mert korábban azt állította, hogy az egri kórház fejlesztése csak magánműködtető bevonásával képzelhető el – jelentette ki kedden Egerben a megyegyűlés Fidesz-frakciójának igazgatója. Ezzel szemben Sós Tamás most bejelentette, hogy az intézmény 4,6-4,8 milliárd forintnyi uniós fejlesztési forrásra számíthat – mondta sajtótájékoztatón Herman István. + </description> + <pubDate> + 2009-10-20 16:28 +0200 + </pubDate> + <link> + http://www.mno.hu/portal/670999 + </link> + <category> + Online + </category> + </item> + <item> + <title> + Oszkóék gyomra nem korog + </title> + <description> + Állítólag százezer ember éhezik Magyarországon, vagyis ott, ahol „nagy a jólét”, ahová nem gyűrűznek be mindenféle válságok, és csupa remek ember rendelkezik az erőforrások és a döntési folyamatok felett. Végül is tízmillióhoz képest mi ez a százezer? Nyilván így gondolja ezt a teljes szocialista élcsapat is, mert egyetlen hangot sem hallattak az ügyben. De talán jobb is. + </description> + <pubDate> + 2009-10-20 16:28 +0200 + </pubDate> + <link> + http://www.mno.hu/portal/670522 + </link> + <category> + Online + </category> + </item> + <item> + <title> + Áramot vezetett a kerítésbe, hat év fegyházbüntetést kapott + </title> + <description> + Hat év fegyházbüntetésre ítélte kedden a Szabolcs-Szatmár-Bereg Megyei Bíróság azt a 41 éves kisari férfit, aki idén áprilisban áramot vezetett lakóházának kerítésébe, később pedig alkoholos állapotban késsel sebezte meg életveszélyesen az édesanyját, az ítélet nem jogerős. + </description> + <pubDate> + 2009-10-20 16:22 +0200 + </pubDate> + <link> + http://www.mno.hu/portal/670994 + </link> + <category> + Online + </category> + </item> + <item> + <title> + „A kormány elvesz, majd nagy csinnadrattával morzsákat oszt” + </title> + <description> + A tehetséggondozásra, az ország jövőjének a megalapozására kell, hogy legyen pénze a kormánynak – mondta Bajnai Gordon a kedden bejelentett tehetségsegítő programról. Sió László, a Fidesz szakpolitikusa szerint a kormány két kézzel elvesz, majd nagy csinnadrattával morzsákat oszt. Igen alacsony a ténylegesen kifizetett források aránya – reagált a hírre Cser-Palkovics András. + </description> + <pubDate> + 2009-10-20 16:18 +0200 + </pubDate> + <link> + http://www.mno.hu/portal/670996 + </link> + <category> + Online + </category> + </item> + <item> + <title> + Hőszivattyút telepítenek az iskolába + </title> + <description> + Mintegy 550 millió forintból teljesen felújítják a békési kistérségi általános iskola és pedagógiai szakszolgálat dr. Hepp Ferencről elnevezett tagintézményét – közölte Békés polgármestere kedden sajtótájékoztatón. A hőszigetelési munkák mellett hőszivattyú telepítésével is próbálják csökkenteni az energiafogyasztást. + </description> + <pubDate> + 2009-10-20 16:13 +0200 + </pubDate> + <link> + http://www.mno.hu/portal/670987 + </link> + <category> + Online + </category> + </item> + </channel> +</rss> + diff --git a/sites/all/modules/feeds/tests/feeds/feeds-tests-files.tpl.php b/sites/all/modules/feeds/tests/feeds/feeds-tests-files.tpl.php new file mode 100644 index 0000000000000000000000000000000000000000..9674abb2d46b3fb06cc4fe25cbc8220752937f71 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds/feeds-tests-files.tpl.php @@ -0,0 +1,6 @@ +Title,published,file,GUID +"Tubing is awesome",205200720,<?php print $files[0]; ?>,0 +"Jeff vs Tom",428112720,<?php print $files[1]; ?>,1 +"Attersee",1151766000,<?php print $files[2]; ?>,2 +"H Street NE",1256326995,<?php print $files[3]; ?>,3 +"La Fayette Park",1256326995,<?php print $files[4]; ?>,4 diff --git a/sites/all/modules/feeds/tests/feeds/feeds-tests-flickr.tpl.php b/sites/all/modules/feeds/tests/feeds/feeds-tests-flickr.tpl.php new file mode 100644 index 0000000000000000000000000000000000000000..92c7ab9cd506996630759e54554cede0adf3d043 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds/feeds-tests-flickr.tpl.php @@ -0,0 +1,203 @@ +<?php +print '<?xml version="1.0" encoding="utf-8" standalone="yes"?>'; +?> +<feed xmlns="http://www.w3.org/2005/Atom" + xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:flickr="urn:flickr:" xmlns:media="http://search.yahoo.com/mrss/"> + + <title>Content from My picks</title> + <link rel="self" href="http://api.flickr.com/services/feeds/photoset.gne?set=72157603970496952&nsid=28242329@N00&lang=en-us" /> + <link rel="alternate" type="text/html" href="http://www.flickr.com/photos/a-barth/sets/72157603970496952"/> + <id>tag:flickr.com,2005:http://www.flickr.com/photos/28242329@N00/sets/72157603970496952</id> + <icon>http://farm1.static.flickr.com/42/86410049_bd6dcdd5f9_s.jpg</icon> + <subtitle>Some of my shots I like best in random order.</subtitle> + <updated>2009-07-09T21:48:04Z</updated> + <generator uri="http://www.flickr.com/">Flickr</generator> + + <entry> + <title>Tubing is awesome</title> + <link rel="alternate" type="text/html" href="http://www.flickr.com/photos/a-barth/3596408735/in/set-72157603970496952/"/> + <id>tag:flickr.com,2005:/photo/3596408735/in/set-72157603970496952</id> + <published>2009-07-09T21:48:04Z</published> + <updated>2009-07-09T21:48:04Z</updated> + <dc:date.Taken>2009-05-01T00:00:00-08:00</dc:date.Taken> + <content type="html"><p><a href="http://www.flickr.com/people/a-barth/">Alex Barth</a> posted a photo:</p> + +<p><a href="http://www.flickr.com/photos/a-barth/3596408735/" title="Tubing is awesome"><img src="http://farm4.static.flickr.com/3599/3596408735_ce2f0c4824_m.jpg" width="240" height="161" alt="Tubing is awesome" /></a></p> + + +<p>Virginia, 2009</p></content> + <author> + <name>Alex Barth</name> + <uri>http://www.flickr.com/people/a-barth/</uri> + </author> + <link rel="license" type="text/html" href="http://creativecommons.org/licenses/by-nc/2.0/deed.en" /> + <link rel="enclosure" type="image/jpeg" href="<?php print $image_urls[0]; ?>" /> + + <category term="color" scheme="http://www.flickr.com/photos/tags/" /> + <category term="film" scheme="http://www.flickr.com/photos/tags/" /> + <category term="virginia" scheme="http://www.flickr.com/photos/tags/" /> + <category term="awesome" scheme="http://www.flickr.com/photos/tags/" /> + <category term="ishootfilm" scheme="http://www.flickr.com/photos/tags/" /> + <category term="va" scheme="http://www.flickr.com/photos/tags/" /> + <category term="badge" scheme="http://www.flickr.com/photos/tags/" /> + <category term="tubing" scheme="http://www.flickr.com/photos/tags/" /> + <category term="fuji160c" scheme="http://www.flickr.com/photos/tags/" /> + <category term="anfamiliebarth" scheme="http://www.flickr.com/photos/tags/" /> + <category term="canon24l" scheme="http://www.flickr.com/photos/tags/" /> + </entry> + <entry> + <title>Jeff vs Tom</title> + <link rel="alternate" type="text/html" href="http://www.flickr.com/photos/a-barth/2640019371/in/set-72157603970496952/"/> + <id>tag:flickr.com,2005:/photo/2640019371/in/set-72157603970496952</id> + <published>2009-07-09T21:45:50Z</published> + <updated>2009-07-09T21:45:50Z</updated> + <dc:date.Taken>2008-06-01T00:00:00-08:00</dc:date.Taken> + <content type="html"><p><a href="http://www.flickr.com/people/a-barth/">Alex Barth</a> posted a photo:</p> + +<p><a href="http://www.flickr.com/photos/a-barth/2640019371/" title="Jeff vs Tom"><img src="http://farm4.static.flickr.com/3261/2640019371_495c3f51a2_m.jpg" width="240" height="159" alt="Jeff vs Tom" /></a></p> + + +</content> + <author> + <name>Alex Barth</name> + <uri>http://www.flickr.com/people/a-barth/</uri> + </author> + <link rel="license" type="text/html" href="http://creativecommons.org/licenses/by-nc/2.0/deed.en" /> + <link rel="enclosure" type="image/jpeg" href="<?php print $image_urls[1]; ?>" /> + + <category term="b" scheme="http://www.flickr.com/photos/tags/" /> + <category term="blackandwhite" scheme="http://www.flickr.com/photos/tags/" /> + <category term="bw" scheme="http://www.flickr.com/photos/tags/" /> + <category term="jeff" scheme="http://www.flickr.com/photos/tags/" /> + <category term="tom" scheme="http://www.flickr.com/photos/tags/" /> + <category term="washingtondc" scheme="http://www.flickr.com/photos/tags/" /> + <category term="blackwhite" scheme="http://www.flickr.com/photos/tags/" /> + <category term="dc" scheme="http://www.flickr.com/photos/tags/" /> + <category term="nikon" scheme="http://www.flickr.com/photos/tags/" /> + <category term="wideangle" scheme="http://www.flickr.com/photos/tags/" /> + <category term="ilfordhp5" scheme="http://www.flickr.com/photos/tags/" /> + <category term="foosball" scheme="http://www.flickr.com/photos/tags/" /> + <category term="20mm" scheme="http://www.flickr.com/photos/tags/" /> + <category term="nikonfe2" scheme="http://www.flickr.com/photos/tags/" /> + <category term="800asa" scheme="http://www.flickr.com/photos/tags/" /> + <category term="foosballtable" scheme="http://www.flickr.com/photos/tags/" /> + <category term="wuzler" scheme="http://www.flickr.com/photos/tags/" /> + <category term="wuzln" scheme="http://www.flickr.com/photos/tags/" /> + <category term="tischfusball" scheme="http://www.flickr.com/photos/tags/" /> + <category term="jeffmiccolis" scheme="http://www.flickr.com/photos/tags/" /> + <category term="ilfordhp5800asa" scheme="http://www.flickr.com/photos/tags/" /> + <category term="widean" scheme="http://www.flickr.com/photos/tags/" /> + </entry> + <entry> + <title>Attersee 1</title> + <link rel="alternate" type="text/html" href="http://www.flickr.com/photos/a-barth/3686290986/in/set-72157603970496952/"/> + <id>tag:flickr.com,2005:/photo/3686290986/in/set-72157603970496952</id> + <published>2009-07-09T21:42:01Z</published> + <updated>2009-07-09T21:42:01Z</updated> + <dc:date.Taken>2009-06-01T00:00:00-08:00</dc:date.Taken> + <content type="html"><p><a href="http://www.flickr.com/people/a-barth/">Alex Barth</a> posted a photo:</p> + +<p><a href="http://www.flickr.com/photos/a-barth/3686290986/" title="Attersee 1"><img src="http://farm4.static.flickr.com/3606/3686290986_334c427e8c_m.jpg" width="240" height="238" alt="Attersee 1" /></a></p> + + +<p>Upper Austria, 2009</p></content> + <author> + <name>Alex Barth</name> + <uri>http://www.flickr.com/people/a-barth/</uri> + </author> + <link rel="license" type="text/html" href="http://creativecommons.org/licenses/by-nc/2.0/deed.en" /> + <link rel="enclosure" type="image/jpeg" href="<?php print $image_urls[2]; ?>" /> + + <category term="lake" scheme="http://www.flickr.com/photos/tags/" /> + <category term="green" scheme="http://www.flickr.com/photos/tags/" /> + <category term="water" scheme="http://www.flickr.com/photos/tags/" /> + <category term="austria" scheme="http://www.flickr.com/photos/tags/" /> + <category term="holga" scheme="http://www.flickr.com/photos/tags/" /> + <category term="toycamera" scheme="http://www.flickr.com/photos/tags/" /> + <category term="ishootfilm" scheme="http://www.flickr.com/photos/tags/" /> + <category term="fujireala" scheme="http://www.flickr.com/photos/tags/" /> + <category term="badge" scheme="http://www.flickr.com/photos/tags/" /> + <category term="100asa" scheme="http://www.flickr.com/photos/tags/" /> + <category term="attersee" scheme="http://www.flickr.com/photos/tags/" /> + <category term="plasticlens" scheme="http://www.flickr.com/photos/tags/" /> + <category term="colornegative" scheme="http://www.flickr.com/photos/tags/" /> + </entry> + <entry> + <title>H Street North East</title> + <link rel="alternate" type="text/html" href="http://www.flickr.com/photos/a-barth/2640845934/in/set-72157603970496952/"/> + <id>tag:flickr.com,2005:/photo/2640845934/in/set-72157603970496952</id> + <published>2008-09-23T13:26:13Z</published> + <updated>2008-09-23T13:26:13Z</updated> + <dc:date.Taken>2008-06-01T00:00:00-08:00</dc:date.Taken> + <content type="html"><p><a href="http://www.flickr.com/people/a-barth/">Alex Barth</a> posted a photo:</p> + +<p><a href="http://www.flickr.com/photos/a-barth/2640845934/" title="H Street North East"><img src="http://farm4.static.flickr.com/3083/2640845934_85c11e5a18_m.jpg" width="240" height="159" alt="H Street North East" /></a></p> + + +<p>Washington DC 2008<br /> +<a href="http://dcist.com/2008/07/07/photo_of_the_day_july_7_2008.php">Photo of the Day July 7 on DCist</a></p></content> + <author> + <name>Alex Barth</name> + <uri>http://www.flickr.com/people/a-barth/</uri> + </author> + <link rel="license" type="text/html" href="http://creativecommons.org/licenses/by-nc/2.0/deed.en" /> + <link rel="enclosure" type="image/jpeg" href="<?php print $image_urls[3]; ?>" /> + + <category term="nightphotography" scheme="http://www.flickr.com/photos/tags/" /> + <category term="b" scheme="http://www.flickr.com/photos/tags/" /> + <category term="blackandwhite" scheme="http://www.flickr.com/photos/tags/" /> + <category term="bw" scheme="http://www.flickr.com/photos/tags/" /> + <category term="night" scheme="http://www.flickr.com/photos/tags/" /> + <category term="washingtondc" scheme="http://www.flickr.com/photos/tags/" /> + <category term="blackwhite" scheme="http://www.flickr.com/photos/tags/" /> + <category term="dc" scheme="http://www.flickr.com/photos/tags/" /> + <category term="nikon" scheme="http://www.flickr.com/photos/tags/" /> + <category term="dof" scheme="http://www.flickr.com/photos/tags/" /> + <category term="wideangle" scheme="http://www.flickr.com/photos/tags/" /> + <category term="explore" scheme="http://www.flickr.com/photos/tags/" /> + <category term="ilfordhp5" scheme="http://www.flickr.com/photos/tags/" /> + <category term="badge" scheme="http://www.flickr.com/photos/tags/" /> + <category term="dcist" scheme="http://www.flickr.com/photos/tags/" /> + <category term="20mm" scheme="http://www.flickr.com/photos/tags/" /> + <category term="hstreet" scheme="http://www.flickr.com/photos/tags/" /> + <category term="nikonfe2" scheme="http://www.flickr.com/photos/tags/" /> + <category term="800asa" scheme="http://www.flickr.com/photos/tags/" /> + <category term="hstreetne" scheme="http://www.flickr.com/photos/tags/" /> + <category term="anfamiliebarth" scheme="http://www.flickr.com/photos/tags/" /> + <category term="ilfordhp5800asa" scheme="http://www.flickr.com/photos/tags/" /> + <category term="hstreetbynight" scheme="http://www.flickr.com/photos/tags/" /> + <category term="forlaia" scheme="http://www.flickr.com/photos/tags/" /> + </entry> + <entry> + <title>La Fayette Park</title> + <link rel="alternate" type="text/html" href="http://www.flickr.com/photos/a-barth/4209685951/in/set-72157603970496952/"/> + <id>tag:flickr.com,2005:/photo/4209685951/in/set-72157603970496952</id> + <published>2009-07-09T21:48:04Z</published> + <updated>2009-07-09T21:48:04Z</updated> + <dc:date.Taken>2009-05-01T00:00:00-08:00</dc:date.Taken> + <content type="html"><p><a href="http://www.flickr.com/people/a-barth/">Alex Barth</a> posted a photo:</p> + + <p><a href="http://www.flickr.com/photos/a-barth/3596408735/" title="Tubing is fun"><img src="http://farm3.staticflickr.com/2675/4209685951_cb073de96f_m.jpg" width="239" height="240" alt="La Fayette park" /></a></p> + + + <p>Virginia, 2009</p></content> + <author> + <name>Alex Barth</name> + <uri>http://www.flickr.com/people/a-barth/</uri> + </author> + <link rel="license" type="text/html" href="http://creativecommons.org/licenses/by-nc/2.0/deed.en" /> + <link rel="enclosure" type="image/jpeg" href="<?php print $image_urls[4]; ?>" /> + + <category term="color" scheme="http://www.flickr.com/photos/tags/" /> + <category term="film" scheme="http://www.flickr.com/photos/tags/" /> + <category term="virginia" scheme="http://www.flickr.com/photos/tags/" /> + <category term="awesome" scheme="http://www.flickr.com/photos/tags/" /> + <category term="ishootfilm" scheme="http://www.flickr.com/photos/tags/" /> + <category term="va" scheme="http://www.flickr.com/photos/tags/" /> + <category term="badge" scheme="http://www.flickr.com/photos/tags/" /> + <category term="tubing" scheme="http://www.flickr.com/photos/tags/" /> + <category term="fuji160c" scheme="http://www.flickr.com/photos/tags/" /> + <category term="anfamiliebarth" scheme="http://www.flickr.com/photos/tags/" /> + <category term="canon24l" scheme="http://www.flickr.com/photos/tags/" /> + </entry> +</feed> \ No newline at end of file diff --git a/sites/all/modules/feeds/tests/feeds/googlenewstz.rss2 b/sites/all/modules/feeds/tests/feeds/googlenewstz.rss2 new file mode 100644 index 0000000000000000000000000000000000000000..66276d3d05465a95570f817e8557ad19ad2ce0b1 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds/googlenewstz.rss2 @@ -0,0 +1,8 @@ +<rss version="2.0"><channel><generator>NFE/1.0</generator><title>Top Stories - Google News</title><link>http://news.google.com?pz=1&ned=us&hl=en</link><language>en</language><webMaster>news-feedback@google.com</webMaster><copyright>&copy;2010 Google</copyright><pubDate>Wed, 06 Jan 2010 15:00:01 GMT+00:00</pubDate><lastBuildDate>Wed, 06 Jan 2010 15:00:01 GMT+00:00</lastBuildDate><image><title>Top Stories - Google News</title><url>http://www.gstatic.com/news/img/bluelogo/en_us/news.gif</url><link>http://news.google.com?pz=1&ned=us&hl=en</link></image> +<item><title>First thoughts: Dems' Black Tuesday - msnbc.com</title><link>http://news.google.com/news/url?fd=R&sa=T&url=http%3A%2F%2Ffirstread.msnbc.msn.com%2Farchive%2F2010%2F01%2F06%2F2166474.aspx&usg=AFQjCNFdbsCs9ORClyOyAOcv9NTwcsKzZQ</link><guid isPermaLink="false">tag:news.google.com,2005:cluster=17593687403189</guid><category>Top Stories</category><pubDate>Wed, 06 Jan 2010 14:26:27 GMT-05:00</pubDate><description><table border="0" cellpadding="2" cellspacing="7" style="vertical-align:top;"><tr><td width="80" align="center" valign="top"><font style="font-size:85%;font-family:arial,sans-serif"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.guardian.co.uk%2Fcommentisfree%2Fmichaeltomasky%2F2010%2Fjan%2F06%2Fchris-dodd-byron-dorgan&amp;usg=AFQjCNGqpLv5bhzKoO6HCYhiJ54B1AQiMw"><img src="http://nt0.ggpht.com/news/tbn/XPqi_u5kO1YrtM/6.jpg" alt="" border="1" width="80" height="80" /><br /><font size="-2">The Guardian</font></a></font></td><td valign="top" class="j"><font style="font-size:85%;font-family:arial,sans-serif"><br /><div style="padding-top:0.8em;"><img alt="" height="1" width="1" /></div><div class="lh"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Ffirstread.msnbc.msn.com%2Farchive%2F2010%2F01%2F06%2F2166474.aspx&amp;usg=AFQjCNFdbsCs9ORClyOyAOcv9NTwcsKzZQ"><b>First thoughts: Dems&#39; Black Tuesday</b></a><br /><font size="-1"><b><font color="#6f6f6f">msnbc.com</font></b></font><br /><font size="-1">Democrats experience a Black Tuesday with retirements from Dodd, Dorgan, and Ritter… While Dodd&#39;s exit is probably a political blessing, the same can&#39;t be said for Dorgan&#39;s or Ritter&#39;s decisions… Republicans are one step closer to putting 11 Senate <b>...</b></font><br /><font size="-1"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.reuters.com%2Farticle%2FpoliticsNews%2FidUSTRE6052ET20100106&amp;usg=AFQjCNHBEpKDZvHhYMOcNY2De4EDLwhXxQ">Dodd&#39;s decision underscores Democrats&#39; vulnerability</a><font size="-1" color="#6f6f6f"><nobr>Reuters</nobr></font></font><br /><font size="-1"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.chron.com%2Fdisp%2Fstory.mpl%2Fap%2Ftop%2Fall%2F6801127.html&amp;usg=AFQjCNFmZ6GwjUTYcIyZ7jP6IE17m0t8bw">Dodd, D-Conn., retiring from Senate; AG to run</a><font size="-1" color="#6f6f6f"><nobr>Houston Chronicle</nobr></font></font><br /><font size="-1"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.miamiherald.com%2Fnews%2Fpolitics%2FAP%2Fstory%2F1411697.html&amp;usg=AFQjCNEtPzHHaLMIX4EMc68ieP7SHfHCfA">Sen. Dodd won&#39;t seek re-election</a><font size="-1" color="#6f6f6f"><nobr>MiamiHerald.com</nobr></font></font><br /><font size="-1" class="p"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.nytimes.com%2Faponline%2F2010%2F01%2F06%2Fus%2FAP-CT-DoddRetirement-Bl.html&amp;usg=AFQjCNHQssq-uyg6reJLjTsMtKjJj_83aQ"><nobr>New York Times</nobr></a>&nbsp;-<a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.washingtonpost.com%2Fwp-dyn%2Fcontent%2Farticle%2F2010%2F01%2F06%2FAR2010010601258.html&amp;usg=AFQjCNGO_LBmoYqmETiD2Y2gdjU9IVxEww"><nobr>Washington Post</nobr></a>&nbsp;-<a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.guardian.co.uk%2Fworld%2F2010%2Fjan%2F06%2Fdemocrat-senators-governors-election-obama&amp;usg=AFQjCNGU16BT2JSIHSYDl831UMHGtchvsQ"><nobr>The Guardian</nobr></a></font><br /><font class="p" size="-1"><a class="p" href="http://news.google.com/news/more?pz=1&amp;ned=us&amp;ncl=dBeDIZhDLhfRYOM2C0cJa6WmMPy3M&amp;topic=h"><nobr><b>all 1,751 news articles&nbsp;&raquo;</b></nobr></a></font></div></font></td></tr></table></description></item> +<item><title>Obama wants to fast track a final health care bill - USA Today</title><link>http://news.google.com/news/url?fd=R&sa=T&url=http%3A%2F%2Fcontent.usatoday.com%2Fcommunities%2Ftheoval%2Fpost%2F2010%2F01%2Fobama-wants-to-fast-track-a-final-health-care-bill%2F1&usg=AFQjCNF1sXxYAFz26OQqKIWh3rBJZxgksg</link><guid isPermaLink="false">tag:news.google.com,2005:cluster=17593688083752</guid><category>Top Stories</category><pubDate>Wed, 06 Jan 2010 13:21:20 GMT+03:00</pubDate><description><table border="0" cellpadding="2" cellspacing="7" style="vertical-align:top;"><tr><td width="80" align="center" valign="top"><font style="font-size:85%;font-family:arial,sans-serif"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.reuters.com%2Farticle%2FidUSTRE5B83ZG20100105&amp;usg=AFQjCNEVrKzaUBu2zEjPQPQ4hzCy6D09LA"><img src="http://nt1.ggpht.com/news/tbn/FWkTTQtlAJkNWM/6.jpg" alt="" border="1" width="80" height="80" /><br /><font size="-2">Reuters</font></a></font></td><td valign="top" class="j"><font style="font-size:85%;font-family:arial,sans-serif"><br /><div style="padding-top:0.8em;"><img alt="" height="1" width="1" /></div><div class="lh"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fcontent.usatoday.com%2Fcommunities%2Ftheoval%2Fpost%2F2010%2F01%2Fobama-wants-to-fast-track-a-final-health-care-bill%2F1&amp;usg=AFQjCNF1sXxYAFz26OQqKIWh3rBJZxgksg"><b>Obama wants to fast track a final health care bill</b></a><br /><font size="-1"><b><font color="#6f6f6f">USA Today</font></b></font><br /><font size="-1">The White House didn&#39;t say much about last night&#39;s health care talks between President Obama and congressional Democrats, but officials made it clear they&#39;re cool with fast-tracking the final phase of legislation, with no public hearings and no <b>...</b></font><br /><font size="-1"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.medpagetoday.com%2FWashington-Watch%2FReform%2F17812&amp;usg=AFQjCNGFLIoORNDfDJFNXxiNWxuAdyMDNQ">Congress Likely to Combine Healthcare Bills Informally</a><font size="-1" color="#6f6f6f"><nobr>MedPage Today</nobr></font></font><br /><font size="-1"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwashingtontimes.com%2Fnews%2F2010%2Fjan%2F06%2Freforms-bode-ill-for-tax-free-health-accounts%2F%3Ffeat%3Dhome_headlines&amp;usg=AFQjCNG9QTVx8kHcEaFWdxr3sKIItoB2Dw">Reforms bode ill for tax-free health accounts</a><font size="-1" color="#6f6f6f"><nobr>Washington Times</nobr></font></font><br /><font size="-1"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fedition.cnn.com%2F2010%2FPOLITICS%2F01%2F06%2Fobama.dems.health.care%2F&amp;usg=AFQjCNHELvYyQ3TAaXqhQtOR5dNkg9qLIA">Sources: Obama, Dems to hold informal talks on health care</a><font size="-1" color="#6f6f6f"><nobr>CNN International</nobr></font></font><br /><font size="-1" class="p"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.nytimes.com%2F2010%2F01%2F06%2Fus%2Fpolitics%2F06cong.html&amp;usg=AFQjCNGcq7OwgK4J1pGeWDZmKrMH9W-Jxg"><nobr>New York Times</nobr></a>&nbsp;-<a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.boston.com%2Fnews%2Fnation%2Fwashington%2Farticles%2F2010%2F01%2F06%2Fdemocrats_focus_on_health_consensus%2F&amp;usg=AFQjCNElF4ajwViPqbvpPD9R2OYeONCE1A"><nobr>Boston Globe</nobr></a>&nbsp;-<a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.kaiserhealthnews.org%2FDaily-Reports%2F2010%2FJanuary%2F06%2FEyes-on-Pelosi-Health-Care.aspx&amp;usg=AFQjCNFS_LBgfNddk8KD2n091RFrqyu15w"><nobr>Kaiser Health News</nobr></a></font><br /><font class="p" size="-1"><a class="p" href="http://news.google.com/news/more?pz=1&amp;ned=us&amp;ncl=dZLcGGGFNPbQ-1My5ui0FO5ST5zTM&amp;topic=h"><nobr><b>all 1,917 news articles&nbsp;&raquo;</b></nobr></a></font></div></font></td></tr></table></description></item> +<item><title>Why the Nexus One Makes Other Android Phones Obsolete - PC World</title><link>http://news.google.com/news/url?fd=R&sa=T&url=http%3A%2F%2Fwww.pcworld.com%2Farticle%2F186006%2Fwhy_the_nexus_one_makes_other_android_phones_obsolete.html&usg=AFQjCNF4WnQcKVu-P5jICC0SNtPAH2IPVw</link><guid isPermaLink="false">tag:news.google.com,2005:cluster=17593685844960</guid><category>Top Stories</category><pubDate>Wed, 06 Jan 2010 13:42:47 GMT+00:00</pubDate><description><table border="0" cellpadding="2" cellspacing="7" style="vertical-align:top;"><tr><td width="80" align="center" valign="top"><font style="font-size:85%;font-family:arial,sans-serif"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.cbc.ca%2Ftechnology%2Fstory%2F2010%2F01%2F05%2Fgoogle-phone.html&amp;usg=AFQjCNGheVBsiY2XOoZ1MhvhbR_Lh6zGPA"><img src="http://nt3.ggpht.com/news/tbn/u_YKGJKq82TQoM/6.jpg" alt="" border="1" width="80" height="80" /><br /><font size="-2">CBC.ca</font></a></font></td><td valign="top" class="j"><font style="font-size:85%;font-family:arial,sans-serif"><br /><div style="padding-top:0.8em;"><img alt="" height="1" width="1" /></div><div class="lh"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.pcworld.com%2Farticle%2F186006%2Fwhy_the_nexus_one_makes_other_android_phones_obsolete.html&amp;usg=AFQjCNF4WnQcKVu-P5jICC0SNtPAH2IPVw"><b>Why the Nexus One Makes Other Android Phones Obsolete</b></a><br /><font size="-1"><b><font color="#6f6f6f">PC World</font></b></font><br /><font size="-1">Google&#39;s new &quot;superphone&quot; runs the latest and greatest version of Android, but don&#39;t expect to see it on older Android phones anytime soon. The Google Nexus One, unveiled on Tuesday, has all the bells and whistles to challenge Apple&#39;s ever-popular <b>...</b></font><br /><font size="-1"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.crn.com%2Fmobile%2F222200427&amp;usg=AFQjCNEVYevIt9wqQZ8y4wspFqATjYQ_lA">What Google&#39;s Nexus One Really Means</a><font size="-1" color="#6f6f6f"><nobr>ChannelWeb</nobr></font></font><br /><font size="-1"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Ffeeds.wired.com%2F~r%2Fwired%2Findex%2F~3%2FMO-ku8369hw%2F&amp;usg=AFQjCNFgvwgM3LIoGoTrMSNiqAvYPsZZAQ">Google Debuts Android-Powered Nexus One &#39;Superphone&#39;</a><font size="-1" color="#6f6f6f"><nobr>Wired News</nobr></font></font><br /><font size="-1"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.reuters.com%2Farticle%2FidUS418924234220100106&amp;usg=AFQjCNHQ-U2aRSw0DP891RC59gRmkZIPjQ">Google Phone Makes a Small Splash</a><font size="-1" color="#6f6f6f"><nobr>Reuters</nobr></font></font><br /><font size="-1" class="p"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.mobileburn.com%2Fnews.jsp%3FId%3D8489&amp;usg=AFQjCNG5LCR6KvvWgtSFFGxctJ4VwoqGpQ"><nobr>Mobile Burn</nobr></a>&nbsp;-<a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.theinquirer.net%2Finquirer%2Fnews%2F1567644%2Fgoogle-releases-nexus-smartphone&amp;usg=AFQjCNFTgpWj8iAnYZIsnC06jkJzkYbQEA"><nobr>Inquirer</nobr></a>&nbsp;-<a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.computerworld.com%2Fs%2Farticle%2F9143115%2FNexus_One_another_tactic_in_Google_s_ad_revenue_strategy&amp;usg=AFQjCNEEev8nSakJJ9vfVc6DuW6jtkM4RQ"><nobr>Computerworld</nobr></a></font><br /><font class="p" size="-1"><a class="p" href="http://news.google.com/news/more?pz=1&amp;ned=us&amp;ncl=djgUAiDaCDB60kMkkiB30PWRy8X7M&amp;topic=h"><nobr><b>all 3,450 news articles&nbsp;&raquo;</b></nobr></a></font></div></font></td></tr></table></description></item> +<item><title>NEWSMAKER-New Japan finance minister a fiery battler - Reuters</title><link>http://news.google.com/news/url?fd=R&sa=T&url=http%3A%2F%2Fwww.reuters.com%2Farticle%2FidUSTOE60509W20100106&usg=AFQjCNGc_qEms8qqrYAmUdQTbFgBlsa51A</link><guid isPermaLink="false">tag:news.google.com,2005:cluster=17593685670703</guid><category>Top Stories</category><pubDate>Wed, 06 Jan 2010 14:05:40 GMT+08:00</pubDate><description><table border="0" cellpadding="2" cellspacing="7" style="vertical-align:top;"><tr><td width="80" align="center" valign="top"><font style="font-size:85%;font-family:arial,sans-serif"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.rte.ie%2Fbusiness%2F2010%2F0106%2Fjapan.html&amp;usg=AFQjCNGQWWOq_Q67WM9NLpuu12eNgAMhbA"><img src="http://nt1.ggpht.com/news/tbn/BVlP-eNLpkIPKM/6.jpg" alt="" border="1" width="80" height="80" /><br /><font size="-2">RTE.ie</font></a></font></td><td valign="top" class="j"><font style="font-size:85%;font-family:arial,sans-serif"><br /><div style="padding-top:0.8em;"><img alt="" height="1" width="1" /></div><div class="lh"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.reuters.com%2Farticle%2FidUSTOE60509W20100106&amp;usg=AFQjCNGc_qEms8qqrYAmUdQTbFgBlsa51A"><b>NEWSMAKER-New Japan finance minister a fiery battler</b></a><br /><font size="-1"><b><font color="#6f6f6f">Reuters</font></b></font><br /><font size="-1">TOKYO, Jan 6 (Reuters) - Japan&#39;s new finance minister, Naoto Kan, is a fiery ruling party heavyweight who faces the tough task of trying to stimulate the economy without upsetting investors by bloating an already huge public debt. <b>...</b></font><br /><font size="-1"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fonline.wsj.com%2Farticle%2FBT-CO-20100106-706288.html%3Fmod%3DWSJ_World_MIDDLEHeadlinesAsia&amp;usg=AFQjCNG0SB2DSq-JypF3hTmbn6g8sD_gfw">Kan To Take Over As Japan&#39;s Finance Chief</a><font size="-1" color="#6f6f6f"><nobr>Wall Street Journal</nobr></font></font><br /><font size="-1"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fedition.cnn.com%2F2010%2FWORLD%2Fasiapcf%2F01%2F06%2Fjapan.finance.minister.fujii%2F&amp;usg=AFQjCNG30pVGVZd1rJDOBbKu58fIDOe7Hg">Japanese finance minister Fujii quits</a><font size="-1" color="#6f6f6f"><nobr>CNN International</nobr></font></font><br /><font size="-1"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fhome.kyodo.co.jp%2Fmodules%2FfstStory%2Findex.php%3Fstoryid%3D479144&amp;usg=AFQjCNGCKkHTxC2EiSqVwMXQOxpfwsEtEw">FOCUS: Figure of Ozawa looms again behind Fujii&#39;s resignation</a><font size="-1" color="#6f6f6f"><nobr>Kyodo News</nobr></font></font><br /><font size="-1" class="p"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.businessweek.com%2Fnews%2F2010-01-06%2Fyen-drops-versus-dollar-as-recovery-fujii-concern-curbs-demand.html&amp;usg=AFQjCNGDSOaJ1bpQh7PsMmjY991lvKkGWg"><nobr>BusinessWeek</nobr></a>&nbsp;-<a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.marketwatch.com%2Fstory%2Fdeputy-pm-kan-named-as-japans-finance-minister-2010-01-06&amp;usg=AFQjCNEBsJZmpuzhn-K7v44k_IOatX2Nig"><nobr>MarketWatch</nobr></a>&nbsp;-<a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.nytimes.com%2F2010%2F01%2F06%2Fworld%2Fasia%2F06japan.html&amp;usg=AFQjCNFs6L5M8MCCG7VuPDcTmE9ek121hw"><nobr>New York Times</nobr></a></font><br /><font class="p" size="-1"><a class="p" href="http://news.google.com/news/more?pz=1&amp;ned=us&amp;ncl=dNT65aGS3zkAmIM8rSpYdo38DRxVM&amp;topic=h"><nobr><b>all 1,156 news articles&nbsp;&raquo;</b></nobr></a></font></div></font></td></tr></table></description></item> +<item><title>Yemen Detains Al-Qaeda Suspects After Embassy Threats - Bloomberg</title><link>http://news.google.com/news/url?fd=R&sa=T&url=http%3A%2F%2Fwww.bloomberg.com%2Fapps%2Fnews%3Fpid%3D20601102%26sid%3DalTEErVe0CIs&usg=AFQjCNGKnUWVDulzUwkJgxAU6QNOPFiHeg</link><guid isPermaLink="false">tag:news.google.com,2005:cluster=17593688042489</guid><category>Top Stories</category><pubDate>Wed, 06 Jan 2010 14:26:39 GMT+03:00</pubDate><description><table border="0" cellpadding="2" cellspacing="7" style="vertical-align:top;"><tr><td width="80" align="center" valign="top"><font style="font-size:85%;font-family:arial,sans-serif"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.telegraph.co.uk%2Fnews%2Fworldnews%2Fmiddleeast%2Fyemen%2F6937316%2FYemen-orders-troops-into-al-Qaeda-strongholds.html&amp;usg=AFQjCNExyEywr12YTiceNZtzlq1NKX-EgQ"><img src="http://nt3.ggpht.com/news/tbn/awRvMhtywz3c-M/6.jpg" alt="" border="1" width="80" height="80" /><br /><font size="-2">Telegraph.co.uk</font></a></font></td><td valign="top" class="j"><font style="font-size:85%;font-family:arial,sans-serif"><br /><div style="padding-top:0.8em;"><img alt="" height="1" width="1" /></div><div class="lh"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.bloomberg.com%2Fapps%2Fnews%3Fpid%3D20601102%26sid%3DalTEErVe0CIs&amp;usg=AFQjCNGKnUWVDulzUwkJgxAU6QNOPFiHeg"><b>Yemen Detains Al-Qaeda Suspects After Embassy Threats</b></a><br /><font size="-1"><b><font color="#6f6f6f">Bloomberg</font></b></font><br /><font size="-1">Jan. 6 (Bloomberg) -- Yemeni security forces said they arrested three suspected al-Qaeda militants linked to a cell of the terrorist group blamed for threats against the US Embassy and other foreign missions. <b>...</b></font><br /><font size="-1"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww1.voanews.com%2Fenglish%2Fnews%2Fmiddle-east%2FYemen-Claims-3-Al-Qaida-Militants-Captured-in-Connection-with-Embassy-Threat-80780967.html&amp;usg=AFQjCNH5X3tiJ8vsrZoEHFWnJDmw6k3sFw">Yemen Claims 3 Al-Qaida Militants Captured in Connection with Embassy Threat</a><font size="-1" color="#6f6f6f"><nobr>Voice of America</nobr></font></font><br /><font size="-1"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fnews.bbc.co.uk%2F1%2Fhi%2Fworld%2Fmiddle_east%2F8443078.stm&amp;usg=AFQjCNGYwV-rqv5PegAz5SsldiRvr1dr_g">Yemen &#39;arrests al-Qaeda suspects&#39; wounded in raid</a><font size="-1" color="#6f6f6f"><nobr>BBC News</nobr></font></font><br /><font size="-1"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fedition.cnn.com%2F2010%2FWORLD%2Fmeast%2F01%2F06%2Fyemen.al.qaeda%2F&amp;usg=AFQjCNFSfbY9qkFcgLRyGQLjfNex-5KNQw">Yemen arrests 3 al Qaeda suspects</a><font size="-1" color="#6f6f6f"><nobr>CNN International</nobr></font></font><br /><font size="-1" class="p"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.google.com%2Fhostednews%2Fafp%2Farticle%2FALeqM5h4NbgbHk88RSPSpOCG5Cg6f6FrvA&amp;usg=AFQjCNHN_z8Ds-OxNhIRl_MZxRtEeAp_dg"><nobr>AFP</nobr></a>&nbsp;-<a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.chicagotribune.com%2Fnews%2Fchi-tc-nw-yemen-qaeda-0105-0106jan06%2C0%2C3246858.story&amp;usg=AFQjCNHto_VTKl2AdlO7yF7D9Uk3CkMlxw"><nobr>Chicago Tribune</nobr></a>&nbsp;-<a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.ynetnews.com%2Farticles%2F0%2C7340%2CL-3830342%2C00.html&amp;usg=AFQjCNHK0DJcyGgEyjaz-fxTXgicYFGnLQ"><nobr>Ynetnews</nobr></a></font><br /><font class="p" size="-1"><a class="p" href="http://news.google.com/news/more?pz=1&amp;ned=us&amp;ncl=duEsXKdHWTVEzZMnfUiaVj_DuDpAM&amp;topic=h"><nobr><b>all 1,584 news articles&nbsp;&raquo;</b></nobr></a></font></div></font></td></tr></table></description></item> +<item><title>Egypt, Hamas exchange fire on Gaza frontier, 1 dead - Reuters</title><link>http://news.google.com/news/url?fd=R&sa=T&url=http%3A%2F%2Fwww.reuters.com%2Farticle%2FidUSTRE60526A20100106&usg=AFQjCNG4UuyUmz-Wd6YhmbdvupkbHbHYAA</link><guid isPermaLink="false">tag:news.google.com,2005:cluster=17593688298004</guid><category>Top Stories</category><pubDate>Wed, 06 Jan 2010 14:26:26 GMT-10:00</pubDate><description><table border="0" cellpadding="2" cellspacing="7" style="vertical-align:top;"><tr><td width="80" align="center" valign="top"><font style="font-size:85%;font-family:arial,sans-serif"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fenglish.aljazeera.net%2Fnews%2Fmiddleeast%2F2010%2F01%2F201016123715308843.html&amp;usg=AFQjCNF5jDZ_Ucs9aLP_5ohKQfuUwTh3Dg"><img src="http://nt3.ggpht.com/news/tbn/L9ZIsOJouvA_fM/6.jpg" alt="" border="1" width="80" height="80" /><br /><font size="-2">Aljazeera.net</font></a></font></td><td valign="top" class="j"><font style="font-size:85%;font-family:arial,sans-serif"><br /><div style="padding-top:0.8em;"><img alt="" height="1" width="1" /></div><div class="lh"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.reuters.com%2Farticle%2FidUSTRE60526A20100106&amp;usg=AFQjCNG4UuyUmz-Wd6YhmbdvupkbHbHYAA"><b>Egypt, Hamas exchange fire on Gaza frontier, 1 dead</b></a><br /><font size="-1"><b><font color="#6f6f6f">Reuters</font></b></font><br /><font size="-1">GAZA (Reuters) - An Egyptian soldier was killed and four Palestinians were wounded in a gunbattle on Wednesday during a protest against an anti-smuggling wall Cairo is building on the Gaza border. The violence was the most serious between Egyptian and <b>...</b></font><br /><font size="-1"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fenglish.aljazeera.net%2Fnews%2Fmiddleeast%2F2010%2F01%2F201016123715308843.html&amp;usg=AFQjCNF5jDZ_Ucs9aLP_5ohKQfuUwTh3Dg">Lethal clashes at Gaza-Egypt border</a><font size="-1" color="#6f6f6f"><nobr>Aljazeera.net</nobr></font></font><br /><font size="-1"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.google.com%2Fhostednews%2Fafp%2Farticle%2FALeqM5hlyBEcozp_DcZhUdakcB2mnTmUoQ&amp;usg=AFQjCNHFBHDUXezq_jQgJFOxw_6c7eghtg">Gaza gunfire kills Egypt policeman: Egypt state TV</a><font size="-1" color="#6f6f6f"><nobr>AFP</nobr></font></font><br /><font size="-1"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fnews.xinhuanet.com%2Fenglish%2F2010-01%2F06%2Fcontent_12766842.htm&amp;usg=AFQjCNGsyoHSwxXprG-AyOwX12dmdd9Q5g">Calm back to Egypt&#39;s borders with Gaza after clashes</a><font size="-1" color="#6f6f6f"><nobr>Xinhua</nobr></font></font><br /><font size="-1" class="p"><a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.ynetnews.com%2Farticles%2F0%2C7340%2CL-3830579%2C00.html&amp;usg=AFQjCNGsfWllg2QfhV4pX-MXEl8VRuIqsg"><nobr>Ynetnews</nobr></a>&nbsp;-<a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.nytimes.com%2Faponline%2F2010%2F01%2F05%2Fworld%2FAP-ML-Palestinians-Sealing-Gaza.html&amp;usg=AFQjCNEf0LP7EnK0t3uIOBOtsPm9rDuVug"><nobr>New York Times</nobr></a>&nbsp;-<a href="http://news.google.com/news/url?fd=R&amp;sa=T&amp;url=http%3A%2F%2Fwww.guardian.co.uk%2Fworld%2F2010%2Fjan%2F06%2Fgeorge-galloway-gaza-aid-convoy&amp;usg=AFQjCNFQyAgvHovIr6y9DmwmjghS7WNRXQ"><nobr>The Guardian</nobr></a></font><br /><font class="p" size="-1"><a class="p" href="http://news.google.com/news/more?pz=1&amp;ned=us&amp;ncl=djNuENxfWRzRYjMzw1nruQqIJxslM&amp;topic=h"><nobr><b>all 635 news articles&nbsp;&raquo;</b></nobr></a></font></div></font></td></tr></table></description></item> +<description>Google News</description></channel></rss> \ No newline at end of file diff --git a/sites/all/modules/feeds/tests/feeds/magento.rss1 b/sites/all/modules/feeds/tests/feeds/magento.rss1 new file mode 100644 index 0000000000000000000000000000000000000000..48c4489f2ab1978fd3d57f90131bb252f602ce07 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds/magento.rss1 @@ -0,0 +1,248 @@ +<?xml version="1.0" encoding="UTF-8"?> +<rdf:RDF xmlns="http://purl.org/rss/1.0/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:sy="http://purl.org/rss/1.0/modules/syndication/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:georss="http://www.georss.org/georss"> + <channel rdf:about="http://www.magentosites.net/rss.xml"> + <title xml:lang="en">Magento Sites Network - A directory listing of Magento Commerce stores</title> + <link>http://www.magentosites.net/</link> + <description xml:lang="en"></description> + <items rdf:nodeID="b1"/> + <sy:updatePeriod>daily</sy:updatePeriod> + </channel> + <rdf:Seq rdf:nodeID="b1"> + <rdf:li>http://www.magentosites.net/node/3473</rdf:li> + <rdf:li>http://www.magentosites.net/node/3472</rdf:li> + <rdf:li>http://www.magentosites.net/node/3471</rdf:li> + <rdf:li>http://www.magentosites.net/node/3470</rdf:li> + <rdf:li>http://www.magentosites.net/node/3468</rdf:li> + <rdf:li>http://www.magentosites.net/node/3466</rdf:li> + <rdf:li>http://www.magentosites.net/node/3465</rdf:li> + <rdf:li>http://www.magentosites.net/node/3464</rdf:li> + <rdf:li>http://www.magentosites.net/node/3463</rdf:li> + <rdf:li>http://www.magentosites.net/node/3461</rdf:li> + <rdf:li>http://www.magentosites.net/node/3459</rdf:li> + <rdf:li>http://www.magentosites.net/node/3458</rdf:li> + <rdf:li>http://www.magentosites.net/node/3457</rdf:li> + <rdf:li>http://www.magentosites.net/node/3456</rdf:li> + <rdf:li>http://www.magentosites.net/node/3455</rdf:li> + <rdf:li>http://www.magentosites.net/node/3454</rdf:li> + <rdf:li>http://www.magentosites.net/node/3453</rdf:li> + <rdf:li>http://www.magentosites.net/node/3452</rdf:li> + <rdf:li>http://www.magentosites.net/node/3451</rdf:li> + <rdf:li>http://www.magentosites.net/node/3450</rdf:li> + </rdf:Seq> + <item rdf:about="http://www.magentosites.net/node/3473"> + <title>Gezondheidswebwinkel</title> + <link>http://www.magentosites.net/store/2010/04/28/gezondheidswebwinkel/index.html</link> + <description><p>Gezondheidswebwinkrl.nl is een online shop voor reformhuis gebaseerde producten.</p></description> + <dc:date rdf:datatype="http://www.w3.org/2001/XMLSchema#dateTime">2010-04-28T03:21:33Z</dc:date> + <dc:creator>dzinestudio</dc:creator> + <dc:subject>Health & Personal Care</dc:subject> + <dc:subject>Grocery, Health & Beauty</dc:subject> + <georss:point>52.214007 6.892291</georss:point> + </item> + <item rdf:about="http://www.magentosites.net/node/3472"> + <title>MyBobino.com</title> + <link>http://www.magentosites.net/store/2010/04/26/mybobinocom/index.html</link> + <description><p>The Bobino helps you organize and shorten your iPod or cell phone earbuds. No more messy lumps of cord in your pockets or purse. Go to www.mybobino.com.</p></description> + <dc:date rdf:datatype="http://www.w3.org/2001/XMLSchema#dateTime">2010-04-26T20:04:09Z</dc:date> + <dc:creator>TacovanhetReve</dc:creator> + <dc:subject>Computer Accessories</dc:subject> + <dc:subject>Gifts</dc:subject> + <dc:subject>MP3 & Media Players</dc:subject> + <dc:subject>Computers & Office</dc:subject> + <dc:subject>Electronics</dc:subject> + <dc:subject>Gifts, Foods & Drinks</dc:subject> + <dc:subject>Other</dc:subject> + <georss:point>51.589322 4.774491</georss:point> + </item> + <item rdf:about="http://www.magentosites.net/node/3471"> + <title>Boxershorts.nl</title> + <link>http://www.magentosites.net/store/2010/04/26/boxershortsnl/index.html</link> + <dc:date rdf:datatype="http://www.w3.org/2001/XMLSchema#dateTime">2010-04-26T12:34:54Z</dc:date> + <dc:creator>tomashesseling</dc:creator> + <dc:subject>Gifts</dc:subject> + <dc:subject>Lingerie</dc:subject> + <dc:subject>Clothing, Shoes & Jewelry</dc:subject> + <dc:subject>Gifts, Foods & Drinks</dc:subject> + <dc:subject>Other</dc:subject> + <georss:point>52.097938 5.109197</georss:point> + </item> + <item rdf:about="http://www.magentosites.net/node/3470"> + <title>Zapa</title> + <link>http://www.magentosites.net/store/2010/04/26/zapa/index.html</link> + <description><p>The Zapa's webshop is based on Magento community edition.</p></description> + <dc:date rdf:datatype="http://www.w3.org/2001/XMLSchema#dateTime">2010-04-26T08:21:34Z</dc:date> + <dc:creator>wecom</dc:creator> + <dc:subject>Clothing & Accessories</dc:subject> + <dc:subject>Clothing, Shoes & Jewelry</dc:subject> + <georss:point>48.872290 2.363026</georss:point> + </item> + <item rdf:about="http://www.magentosites.net/node/3468"> + <title>Bandenshoppen</title> + <link>http://www.magentosites.net/store/2010/04/26/bandenshoppen/index.html</link> + <description><p>Bandenshoppen is een Magento webshop waarin voor iedere auto de juiste band te vinden is. Of het nu is voor een bestelwagen, SUV of personenwagen, de juiste band van alle grote merken kunt u hier eenvoudig vinden.</p> +<p>Met slechts de code op uw band kiest u de juiste winter- of zomerband.</p></description> + <dc:date rdf:datatype="http://www.w3.org/2001/XMLSchema#dateTime">2010-04-26T07:41:01Z</dc:date> + <dc:creator>Eigen en Wijze Communicatie</dc:creator> + <dc:subject>Automotive</dc:subject> + <dc:subject>Tools, Auto & Industrial</dc:subject> + <georss:point>52.792620 4.786409</georss:point> + </item> + <item rdf:about="http://www.magentosites.net/node/3466"> + <title>Furniture For You (Rugby) LTD</title> + <link>http://www.magentosites.net/store/2010/04/23/furniture-for-you-rugby-ltd/index.html</link> + <description><p>Furniture For You (Rugby) LTD is a large discount furniture store based in Rugby, Warwickshire. Huge selections of Bedroom, Living room &amp; Dining room furniture made by top manufacturers in the UK, Italy &amp; Germany.</p></description> + <dc:date rdf:datatype="http://www.w3.org/2001/XMLSchema#dateTime">2010-04-23T13:34:28Z</dc:date> + <dc:creator>furnitureforyoultd</dc:creator> + <dc:subject>Furniture & Décor</dc:subject> + <dc:subject>Home & Garden</dc:subject> + <georss:point>52.372879 -1.286754</georss:point> + </item> + <item rdf:about="http://www.magentosites.net/node/3465"> + <title>GoedHip</title> + <link>http://www.magentosites.net/store/2010/04/22/goedhip/index.html</link> + <description><p>GoedHip is een Magento webshop met hippe fair trade, ecologische en biologische producten.</p></description> + <dc:date rdf:datatype="http://www.w3.org/2001/XMLSchema#dateTime">2010-04-22T12:02:35Z</dc:date> + <dc:creator>Goedhip</dc:creator> + <dc:subject>Gifts</dc:subject> + <dc:subject>Gifts, Foods & Drinks</dc:subject> + <georss:point>52.091262 5.122748</georss:point> + </item> + <item rdf:about="http://www.magentosites.net/node/3464"> + <title>Lampenwereld</title> + <link>http://www.magentosites.net/store/2010/04/22/lampenwereld/index.html</link> + <description><p>Online Lightstore with a big variation of lights. All prizes include tax, lightbulb and shipment.</p></description> + <dc:date rdf:datatype="http://www.w3.org/2001/XMLSchema#dateTime">2010-04-22T11:43:40Z</dc:date> + <dc:creator>lampenwereld</dc:creator> + <dc:subject>Furniture & Décor</dc:subject> + <dc:subject>Home & Garden</dc:subject> + <georss:point>51.581985 4.732600</georss:point> + </item> + <item rdf:about="http://www.magentosites.net/node/3463"> + <title>Bumblejax</title> + <link>http://www.magentosites.net/store/2010/04/21/bumblejax/index.html-0</link> + <dc:date rdf:datatype="http://www.w3.org/2001/XMLSchema#dateTime">2010-04-21T21:42:01Z</dc:date> + <dc:creator>coreyd74</dc:creator> + <dc:subject>Art, Culture & Leisure</dc:subject> + <dc:subject>Photography</dc:subject> + <georss:point>47.622748 -122.334355</georss:point> + </item> + <item rdf:about="http://www.magentosites.net/node/3461"> + <title>SendraValencia</title> + <link>http://www.magentosites.net/store/2010/04/20/sendravalencia/index.html</link> + <description><p>Tienda de Botas Sendra en Internet. Sendra Boots Store.<br /> +En Botas Sendra Valencia se ha cuidado al máximo el diseño y la navegación. Integración con almacenes y Courier.</p></description> + <dc:date rdf:datatype="http://www.w3.org/2001/XMLSchema#dateTime">2010-04-20T09:32:36Z</dc:date> + <dc:creator>Onestic</dc:creator> + <dc:subject>Shoes</dc:subject> + <dc:subject>Clothing, Shoes & Jewelry</dc:subject> + <georss:point>39.469008 -0.371995</georss:point> + </item> + <item rdf:about="http://www.magentosites.net/node/3459"> + <title>Snowcountry</title> + <link>http://www.magentosites.net/store/2010/04/19/snowcountry/index.html</link> + <description><p>Freeski en Snowboard webshop</p> +<p>Snowcountry.nl richt zich voornamelijk op de actieve boarder en skiër, je vind hier merken die niet op elke straathoek te koop zijn, sterker nog veelal zijn de ski- en snowboard collecties alleen bij Snowcountry online verkrijgbaar.</p></description> + <dc:date rdf:datatype="http://www.w3.org/2001/XMLSchema#dateTime">2010-04-19T15:35:29Z</dc:date> + <dc:creator>Snowcountry</dc:creator> + <dc:subject>All Sports & Outdoors</dc:subject> + <dc:subject>Athletic & Outdoor Clothing</dc:subject> + <dc:subject>Outdoor Recreation</dc:subject> + <dc:subject>Sports & Outdoors</dc:subject> + <georss:point>52.179226 5.507952</georss:point> + </item> + <item rdf:about="http://www.magentosites.net/node/3458"> + <title>Floriculture Australia - Wholesale Florist</title> + <link>http://www.magentosites.net/store/2010/04/18/floriculture-australia-wholesale-florist/index.html</link> + <description><p>Floriculture Australia Wholesale Florist was set up as a cut flower distribution business a few years ago with a distinct difference to other wholesale florists - we quality check all product, we provide a high level of personal and professional service and a respect for the environment, ourselves and those we deal with.</p> +<p>Originally, the family bought a country property to have some room for the dogs to roam around back in the mid 1990’s. This property just happened to have some floristry foliage trees, lavender and daffodils. Max, then in his late 20’s, took on a new industry, a new career and loads and loads of hard work.</p> +<p>Today, the business has expanded, Max has married had some kiddies, further property purchases have been made, and a workforce employed to help with all that hard work!</p> +<p>The driving philosophy has always been to produce and distribute quality flowers in a sustainable manner. We use natural forms of pest control, so you can be sure any flowers supplied by us at Floriculture.com.au are not only fresh and beautiful – they are safe to handle.</p></description> + <dc:date rdf:datatype="http://www.w3.org/2001/XMLSchema#dateTime">2010-04-18T12:52:09Z</dc:date> + <dc:creator>MaxTheFlowerGuy</dc:creator> + <dc:subject>Flowers</dc:subject> + <dc:subject>Gifts, Foods & Drinks</dc:subject> + <georss:point>-37.814470 145.437914</georss:point> + </item> + <item rdf:about="http://www.magentosites.net/node/3457"> + <title>Flower Bunch</title> + <link>http://www.magentosites.net/store/2010/04/18/flower-bunch/index.html</link> + <description><p>FlowerBunch has Australia wide flower delivery direct from our own flower farm and wholesale flower distribution shed. This ensures the freshest flowers, discounted flowers and seasonal specials and next day free flower delivery.</p> +<p>The driving philosophy for the company has always been to produce quality flowers in a sustainable manner. Our flowers are grown using hydroponic organic flower production practices, so you can be sure any flowers supplied by us at http://FlowerBunch.com.au are not only fresh and beautiful, they are also safe to handle.</p> +<p>Our farm fresh cut flower delivery is available to Sydney, Melbourne, Brisbane, Canberra, Adelaide and all eastern states of Australia. Some remote areas may take a day or two extra for delivery. All flowers are sent direct from our farm, not through a relay/referral service.</p></description> + <dc:date rdf:datatype="http://www.w3.org/2001/XMLSchema#dateTime">2010-04-18T12:42:54Z</dc:date> + <dc:creator>MaxTheFlowerGuy</dc:creator> + <dc:subject>Flowers</dc:subject> + <dc:subject>Gifts, Foods & Drinks</dc:subject> + <georss:point>-37.812267 145.432457</georss:point> + </item> + <item rdf:about="http://www.magentosites.net/node/3456"> + <title>Conec - elektronische Bauelemente</title> + <link>http://www.magentosites.net/store/2010/04/15/conec-elektronische-bauelemente/index.html</link> + <description><p>CONEC entwickelt und produziert in modernen Produktionsstätten mit Hightech-Technologie in zuverlässigen Produktionsprozessen. Die Vielfalt der CONEC-Produktpalette bietet zuverlässige und effektive Antworten auf alle Fragen der Verbindungstechnik, bei Geräteherstellern, Subunternehmern und Kabelverarbeitern. CONEC ist in allen Industriebereichen zu Hause.</p></description> + <dc:date rdf:datatype="http://www.w3.org/2001/XMLSchema#dateTime">2010-04-15T09:07:48Z</dc:date> + <dc:creator>simone.meisner</dc:creator> + <dc:subject>Other</dc:subject> + <georss:point>51.674817 8.377200</georss:point> + </item> + <item rdf:about="http://www.magentosites.net/node/3455"> + <title>Liemke - technisch chemische Artikel</title> + <link>http://www.magentosites.net/store/2010/04/15/liemke-technisch-chemische-artikel/index.html</link> + <description><p>Hochwertige Klebstoffe, Aerosole &amp; Reiniger aus dem Hause LIEMKE</p> +<p>Überzeugen Sie sich von der Qualität der professionellen chemischen Produkte. Das Sortiment der Hochleistungs-Produkte der Qualitätsmarke "LK" umfasst hochwertige Klebstoffe, Schmiermittel, Aerosole, Dichtstoffe, Silikone, Reiniger, Desinfektionsmittel und Trennmittel für chemisch-technische Anwendungen, für die Bauchemie und die professionelle Desinfektion.</p></description> + <dc:date rdf:datatype="http://www.w3.org/2001/XMLSchema#dateTime">2010-04-15T08:59:45Z</dc:date> + <dc:creator>simone.meisner</dc:creator> + <dc:subject>Other</dc:subject> + <georss:point>51.956925 8.578393</georss:point> + </item> + <item rdf:about="http://www.magentosites.net/node/3454"> + <title>Peitz Werkzeug und Outdoorbekleidung</title> + <link>http://www.magentosites.net/store/2010/04/15/peitz-werkzeug-und-outdoorbekleidung/index.html</link> + <description><p>Bei Peitz Werkzeuge finden Sie ein breites Sortiment an erstklassigen Werkzeugen und hochwertigem Outdoor-Equipment. Wir führen die Marken Hitachi, Milwaukee, Matador, BP, Yeti, Nordisk, Tortuga u.v.m.</p> +<p>Über 1.000 Artikel stehen in diesem Shop zur Verfügung. </p> +<p>Erfahrung, Service und günstige Preise überzeugen!</p></description> + <dc:date rdf:datatype="http://www.w3.org/2001/XMLSchema#dateTime">2010-04-15T08:46:18Z</dc:date> + <dc:creator>simone.meisner</dc:creator> + <dc:subject>Power & Hand Tools</dc:subject> + <dc:subject>Tools, Auto & Industrial</dc:subject> + <georss:point>51.791418 8.420595</georss:point> + </item> + <item rdf:about="http://www.magentosites.net/node/3453"> + <title>Großewinkelmann Tor- und Zaunsysteme</title> + <link>http://www.magentosites.net/store/2010/04/15/grossewinkelmann-tor-und-zaunsysteme/index.html</link> + <description><p>Großewinkelmann bietet Zaun- und Torsysteme sowie Stall- und Weidetechnik an. Produktideen und jahrzentelange Erfahrung bringen den entscheidenden Vorteil für den Kunden. Seit mehr als 60 Jahren steht Großewinkelmann für Qualität und persönlichen Service rund um Zäune und Tore.</p></description> + <dc:date rdf:datatype="http://www.w3.org/2001/XMLSchema#dateTime">2010-04-15T08:39:25Z</dc:date> + <dc:creator>simone.meisner</dc:creator> + <dc:subject>Other</dc:subject> + <georss:point>51.862318 8.435454</georss:point> + </item> + <item rdf:about="http://www.magentosites.net/node/3452"> + <title>Möbius Werbemittel</title> + <link>http://www.magentosites.net/store/2010/04/15/mobius-werbemittel/index.html</link> + <description><p>Möbius Werbemittel bietet einen Onlineshop für Werbemittel und Werbegeschenke. Ein breites Sortiment aus vielen Bereichen bietet optimale Auswahl. Finden Sie Feuerzeuge, Schluesselanhänger, Schirme, Tassen, Schreibgeraete, Buero- und Geschäftsausstattung und vieles mehr.<br /> +Der schnelle Druckservice und die kompetente Beratung runden das Angebot ab.</p></description> + <dc:date rdf:datatype="http://www.w3.org/2001/XMLSchema#dateTime">2010-04-15T08:05:42Z</dc:date> + <dc:creator>simone.meisner</dc:creator> + <dc:subject>Other</dc:subject> + <georss:point>49.544584 8.346575</georss:point> + </item> + <item rdf:about="http://www.magentosites.net/node/3451"> + <title>watch store</title> + <link>http://www.magentosites.net/store/2010/04/14/watch-store/index.html</link> + <description><p>watches, is a fast growing e-shop in Greece. You can find a big variety of whell priced watches.</p></description> + <dc:date rdf:datatype="http://www.w3.org/2001/XMLSchema#dateTime">2010-04-14T21:44:32Z</dc:date> + <dc:creator>webo2</dc:creator> + <dc:subject>Watches</dc:subject> + <dc:subject>Clothing, Shoes & Jewelry</dc:subject> + <georss:point>37.443287 24.942652</georss:point> + </item> + <item rdf:about="http://www.magentosites.net/node/3450"> + <title>Fishingtime.com</title> + <link>http://www.magentosites.net/store/2010/04/14/fishingtimecom/index.html</link> + <description><p>Fishingtime.com is a very fast growing fishing tackle e-shop. You can find everything about fishing in competitive prices and services.</p></description> + <dc:date rdf:datatype="http://www.w3.org/2001/XMLSchema#dateTime">2010-04-14T07:21:29Z</dc:date> + <dc:creator>dimmeneidis</dc:creator> + <dc:subject>All Sports & Outdoors</dc:subject> + <dc:subject>Sports & Outdoors</dc:subject> + <georss:point>37.953416 23.692998</georss:point> + </item> +</rdf:RDF> diff --git a/sites/all/modules/feeds/tests/feeds/many_nodes.csv b/sites/all/modules/feeds/tests/feeds/many_nodes.csv new file mode 100644 index 0000000000000000000000000000000000000000..3026f54e07c15be14cc0d774dd9af564ee7ccb20 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds/many_nodes.csv @@ -0,0 +1,87 @@ +Title,Body,published,GUID +"Ut wisi enim ad minim veniam", "Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat.",205200720,2 +"Duis autem vel eum iriure dolor", "Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi.",428112720,3 +"Nam liber tempor", "Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum.",1151766000,1 +Typi non habent"", "Typi non habent claritatem insitam; est usus legentis in iis qui facit eorum claritatem.",1256326995,4 +"Lorem ipsum","Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat.",1251936720,1 +"Investigationes demonstraverunt", "Investigationes demonstraverunt lectores legere me lius quod ii legunt saepius.",946702800,5 +"Claritas est etiam", "Claritas est etiam processus dynamicus, qui sequitur mutationem consuetudium lectorum.",438112720,6 +"Mirum est notare", "Mirum est notare quam littera gothica, quam nunc putamus parum claram, anteposuerit litterarum formas humanitatis per seacula quarta decima et quinta decima.",1151066000,7 +"Eodem modo typi", "Eodem modo typi, qui nunc nobis videntur parum clari, fiant sollemnes in futurum.",1212891020,8 +"Ut quam dolor", "Ut quam dolor, aliquam pretium elementum ac, auctor eu tortor.",1200837000,9 +"Sed id dignissim lorem", "Donec nec urna mauris. Duis tincidunt",1201000000,10 +"Aliquam feugiat diam", "Aliquam feugiat diam ac enim egestas ut cursus lacus fermentum.",1210922021,11 +"Proin et fringilla leo", "Maecenas rhoncus velit quis nibh convallis pharetra.",1201832100,12 +"Suspendisse potenti", "Integer commodo elit et arcu dapibus at hendrerit nunc dapibus.",1122838020,12 +"Nunc eu lectus nisi", "Nunc eu lectus nisi, sed vestibulum mauris. Sed tincidunt vehicula sem, eu tincidunt massa ultrices vel.",1200827015,13 +"Mauris tellus erat", "",1201845210,13 +"Pellentesque porttitor gravida magna", "Pellentesque porttitor gravida magna, ut lacinia risus suscipit a. Nunc molestie molestie massa non auctor.",1211835320,14 +"Pellentesque facilisis ultrices", "Pellentesque facilisis ultrices risus non porttitor. Donec dapibus velit in metus consectetur et ullamcorper velit volutpat.",1190835120,15 +"Sed eros lectus", "Sed eros lectus, mollis vel commodo et, molestie vel urna.",1151825020,16 +"Morbi consectetur fringilla dolor", "Morbi consectetur fringilla dolor. Morbi eleifend pharetra purus, non facilisis tortor ullamcorper sed.",1201745220,17 +"Vivamus vitae lectus", "Vivamus vitae lectus ac urna tempus dapibus eu consectetur massa. Donec vitae arcu lectus, non ornare nunc. ",1201825020,18 +"Fusce est felis", "Fusce est felis, tincidunt eu congue id, placerat nec massa.",1201836001,19 +"Duis tristique velit", "Duis tristique velit vitae lacus malesuada sed commodo quam commodo.",1201832024,20 +"In ac felis neque", "Integer dictum sapien eget nunc commodo convallis.",1201830020,21 +"Proin a mi nulla", "Proin a mi nulla, sodales mattis nunc.",1201822020,21 +"Pellentesque vitae massa", "Pellentesque vitae massa elementum augue varius suscipit at quis turpis.",1201810020,22 +"Proin dapibus", "Nam pulvinar urna vel eros aliquam hendrerit.",1201636020,23 +"Donec", "Nulla pulvinar felis nec nulla pretium id pulvinar risus scelerisque.",1201336020,24 +"Quisque dictum sagittis purus", "Quisque dictum sagittis purus, nec tristique magna sagittis nec. Mauris tortor nisl, cursus sit amet varius vitae, posuere in ipsum.",1201236020,25 +"Praesent", "Praesent tincidunt vulputate turpis non faucibus",1201132020,26 +"Vestibulum", "Vestibulum volutpat interdum elit, quis aliquam leo lobortis non.",1201036020,27 +"Aliquam", "Aliquam fringilla lobortis mollis.",1201022020,28 +"Etiam faucibus", "Etiam faucibus, quam vitae sollicitudin sagittis, nisl metus ultricies risus, sed convallis nibh risus ut libero.",1201021020,29 +"In hac habitasse", "Proin justo sapien, dapibus vel dictum non, vestibulum in neque.",1201010020,30 +"Mauris risus dolor", "Maecenas rutrum diam suscipit mi feugiat venenatis.",1201005011,31 +"Pellentesque habitant", "Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.",1201001010,32 +"Duis convallis", "Duis convallis quam non eros suscipit iaculis. Aliquam erat volutpat.",1201000010,33 +"Proin adipiscing mi vel", "Proin nibh est, gravida ac condimentum viverra, hendrerit sodales tellus.",1200901010,34 +"Nullam sodales", "Nullam sodales laoreet suscipit.",1200851010,35 +"Duis ornare pulvinar purus", "Duis ornare pulvinar purus, at pulvinar nulla hendrerit vel.",1200801010,35 +"Mauris sit amet nibh risus", "Mauris sit amet nibh risus, eget eleifend ante. Donec imperdiet ante quis ligula condimentum consectetur.",1200761010,36 +"Fusce sit amet mi vitae", "Fusce sit amet mi vitae ipsum tempor rhoncus. Aenean sit amet diam erat, fringilla porttitor felis.",1200751010,37 +"Cras blandit ornare", "Cras blandit ornare augue in tempor. Curabitur vitae dolor nibh, sed molestie metus.",1200601010,37 +"Proin at eros", "Proin at eros ut est laoreet tristique. Duis ullamcorper, nisi eu gravida mattis, nibh ligula laoreet lectus, vitae elementum augue sem non nisl.",1200451010,38 +"Vivamus sem purus", "Vivamus sem purus, viverra eget faucibus et, imperdiet vel massa.",1200551010,40 +"Ut id nisl", "Morbi suscipit tincidunt ultrices. Suspendisse ornare aliquet elit ac accumsan.",1200121010,41 +"Sed libero leo", "Phasellus ut sem sit amet justo sagittis placerat.",1200202010,42 +"Quisque eros nunc", "Quisque eros nunc, iaculis id bibendum ut, imperdiet ut sem. Donec mi quam, ultricies sit amet ornare et, lacinia non libero.",1201222010,43 +"Vestibulum vulputate", "Vestibulum vulputate, lacus quis fringilla imperdiet, nisi mi consequat lacus, eu sodales nisl nisi venenatis lacus.",1200022010,44 +"Suspendisse sed", "Suspendisse sed est eget quam imperdiet laoreet.",1202200010,45 +"Duis a lacus odio", "Duis a lacus odio. Nam luctus sapien id leo vestibulum tristique. Cras eu nisi velit, a aliquet turpis. Maecenas ligula est, ullamcorper vel placerat sit amet, scelerisque et lectus. Nunc velit massa; scelerisque et pretium non, sollicitudin vel tellus? Donec leo turpis, tempus dignissim placerat vel, venenatis a augue. Etiam condimentum porttitor urna, quis scelerisque justo pulvinar eget. Fusce nec nibh non enim mattis posuere. Cras pulvinar erat eget ante gravida congue. Pellentesque ultrices metus ut nunc suscipit id imperdiet nunc ornare. Nam consectetur erat quis massa congue vehicula eget vestibulum turpis. Morbi ac eleifend felis.",1202200010,45 +"Vivamus tempor", "Vivamus tempor, mi a pulvinar convallis, massa enim pulvinar metus, nec fringilla quam ante eu erat. Cras dignissim, felis non euismod iaculis, est massa posuere diam, et auctor libero ipsum ac ipsum? Nullam iaculis, eros ac sodales sollicitudin; tellus ligula volutpat urna, eget mattis felis metus ac augue.",1202200010,46 +"Sed molestie", "Sed molestie dolor sit amet neque dictum egestas? Aliquam vitae dui mi. Phasellus fermentum volutpat augue, quis ornare tortor facilisis ac. Suspendisse potenti. Duis facilisis massa at elit pharetra sit amet condimentum est pharetra.",1202200010,47 +"Donec ut", "Donec ut est ipsum. Pellentesque porttitor eleifend neque non malesuada. Morbi sollicitudin varius dapibus. Morbi sit amet risus leo, eu suscipit urna. Suspendisse velit ligula, suscipit ac rhoncus eu, convallis in eros",1202100010,48 +"Duis ut dolor sem", "Duis ut dolor sem. Donec convallis, nunc quis pulvinar tempus, leo est blandit lacus, et elementum lectus nisl et purus. Mauris eros eros, iaculis volutpat pretium euismod, porta id arcu. Integer tellus lacus, imperdiet sed vulputate varius, dignissim vel nisi. In porta molestie fermentum.",1202222101,49 +"Fusce sodales luctus porta", "Fusce sodales luctus porta. Curabitur pellentesque tincidunt tristique. In hac habitasse platea dictumst.",1202123211,50 +"In egestas lectus a sapien", "In egestas lectus a sapien sollicitudin nec blandit metus scelerisque. Proin et tortor eget risus congue sollicitudin. In auctor interdum turpis porta commodo. Donec faucibus elementum nibh, a egestas nisi tincidunt id.",1202232323,51 +"Aliquam semper", "Aliquam semper egestas aliquet. Aliquam tristique velit sit amet leo sodales aliquam. Praesent cursus ipsum quis odio aliquet eu eleifend velit aliquam? Maecenas consequat lobortis augue, at venenatis enim hendrerit quis.",1202242452,52 +"Curabitur tortor", "Curabitur tortor turpis, commodo eu pretium ac, sollicitudin a augue. ",1201341344,53 +"Duis venenatis", "Duis venenatis lorem vel sapien suscipit consectetur. In vel lectus neque, ut rutrum sapien.",1204564533,54 +"Phasellus ipsum metus", "Phasellus ipsum metus; suscipit nec malesuada et, fermentum eu nulla. Vivamus id libero in ligula gravida tristique at sed nibh. Cras congue, risus posuere hendrerit pharetra, ante justo eleifend sem, vitae faucibus lacus neque rhoncus urna.",1123452333,55 +"Nullam porta", "Nullam porta, nisl eu ornare rhoncus, dolor tortor scelerisque justo, non placerat mauris purus vel mauris.",1067356233,56 +"", "Pellentesque eget ante sit amet turpis vestibulum posuere ut non ipsum. Suspendisse potenti. ",1202122311,57 +"Pellentesque eget ante sit", "Pellentesque fringilla mi eu diam fermentum condimentum",1200010001,58 +"Praesent pellentesque quam nec", "Praesent pellentesque quam nec ligula accumsan faucibus. Duis non nisi ante. Mauris eu ullamcorper urna.",1200000000,59 +"Pellentesque habitant morbi tristique", "Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Phasellus elit risus, interdum sed iaculis a, vehicula in eros.",1000000000,60 +"Mauris lobortis luctus risus mattis luctus", "Mauris lobortis luctus risus mattis luctus. Praesent metus mi, euismod quis accumsan vel, placerat id dui. Donec sed erat vel arcu hendrerit hendrerit nec posuere metus! Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.",1202012011,61 +"Mauris sed felis ut tortor gravida", "Mauris sed felis ut tortor gravida vestibulum vel ac enim!",1202123943,62 +"Sed vehicula, ipsum non dignissim tristique", "Sed vehicula, ipsum non dignissim tristique, urna dui gravida elit, id tempor nunc eros sit amet ligula.",1202323423,63 +"Duis consequat sagittis lectus id semper", "Duis consequat sagittis lectus id semper. Phasellus semper ante at leo faucibus venenatis.",1203124344,64 +"Nunc malesuada convallis neque", "Nunc malesuada convallis neque ac tincidunt. Etiam hendrerit mi at arcu bibendum eget viverra mi fringilla.",1202334234,65 +"Donec et ante mi?", "Donec et ante mi? Nullam auctor suscipit nibh quis dignissim. Etiam non lorem eros, vel auctor quam.",1205646554,66 +"Cras consequat", "Cras consequat, ligula quis pulvinar ultrices, dui sem venenatis leo, et dignissim ante mi vitae ipsum.",1202345600,67 +"Suspendisse convallis tempor adipiscing", "Suspendisse convallis tempor adipiscing. Vivamus ut ullamcorper nulla.",1202203200,68 +"Vestibulum ante ipsum primis in faucibus orci luctus", "Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae.",1202202300,69 +"Maecenas consectetur quam ac risus iaculis pharetra", "Maecenas consectetur quam ac risus iaculis pharetra. Nam metus velit, mattis ac interdum ac, volutpat et diam.",1202323430,70 +"Ut odio massa", "Ut odio massa, luctus aliquam pretium non, imperdiet quis risus.",1202200000,71 +"Curabitur sapien neque", "Curabitur sapien neque, elementum nec iaculis non, commodo nec arcu.",1206000000,72 +"Duis a arcu felis", "Duis a arcu felis, id tristique lectus. Nullam porta mi vitae erat suscipit vulputate?",1202906458,73 +"Cras sollicitudin lobortis rutrum", "Cras sollicitudin lobortis rutrum. Nunc vitae nisl nec orci laoreet cursus.",1204534599,74 +"Cras fringilla commodo posuere", "Cras fringilla commodo posuere. Nam sed justo dolor, vel pharetra odio.",1205345344,75 +"In fringilla erat et mi consequat convallis", "In fringilla erat et mi consequat convallis. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.",1202455443,76 +"Aliquam in tellus nibh", "Aliquam in tellus nibh, ut pharetra metus. Fusce tempor, lectus eget ultrices sagittis, ipsum est sagittis urna, vel porttitor magna sem ac diam.",1207566663,77 +"Nunc gravida, leo sed faucibus viverra", "Nunc gravida, leo sed faucibus viverra, orci sapien mollis tellus, sit amet venenatis odio augue eu nunc.",1203453245,78 +"Vestibulum tristique sodales arcu", "Vestibulum tristique sodales arcu, nec dignissim mauris rutrum et.",1202230010,79 +"Praesent porttitor viverra rhoncus", "Praesent porttitor viverra rhoncus! Nulla malesuada elit et diam semper quis vehicula metus euismod.",1209200010,80 diff --git a/sites/all/modules/feeds/tests/feeds/nodes.csv b/sites/all/modules/feeds/tests/feeds/nodes.csv new file mode 100644 index 0000000000000000000000000000000000000000..8b1b46d8dc558f9eba957f73bd7bbb262ad909f8 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds/nodes.csv @@ -0,0 +1,10 @@ +Title,Body,published,GUID +"Ut wisi enim ad minim veniam", "Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat.",205200720,2 +"Duis autem vel eum iriure dolor", "Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi.",428112720,3 +"Nam liber tempor", "Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum.",1151766000,1 +Typi non habent"", "Typi non habent claritatem insitam; est usus legentis in iis qui facit eorum claritatem.",1256326995,4 +"Lorem ipsum","Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat.",1251936720,1 +"Investigationes demonstraverunt", "Investigationes demonstraverunt lectores legere me lius quod ii legunt saepius.",946702800,5 +"Claritas est etiam", "Claritas est etiam processus dynamicus, qui sequitur mutationem consuetudium lectorum.",438112720,6 +"Mirum est notare", "Mirum est notare quam littera gothica, quam nunc putamus parum claram, anteposuerit litterarum formas humanitatis per seacula quarta decima et quinta decima.",1151066000,7 +"Eodem modo typi", "Eodem modo typi, qui nunc nobis videntur parum clari, fiant sollemnes in futurum.",1201936720,8 \ No newline at end of file diff --git a/sites/all/modules/feeds/tests/feeds/nodes.csv.php b/sites/all/modules/feeds/tests/feeds/nodes.csv.php new file mode 100644 index 0000000000000000000000000000000000000000..a8cfca3cadaeb56c1da320b8b419a11d940bc2a0 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds/nodes.csv.php @@ -0,0 +1,78 @@ +<?php +/** + * @file + * Result of nodes.csv file parsed by ParserCSV.inc + */ + +$control_result = array ( + 0 => + array ( + 0 => 'Title', + 1 => 'Body', + 2 => 'published', + 3 => 'GUID', + ), + 1 => + array ( + 0 => 'Ut wisi enim ad minim veniam', + 1 => ' Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat.', + 2 => '205200720', + 3 => '2', + ), + 2 => + array ( + 0 => 'Duis autem vel eum iriure dolor', + 1 => ' Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi.', + 2 => '428112720', + 3 => '3', + ), + 3 => + array ( + 0 => 'Nam liber tempor', + 1 => ' Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum.', + 2 => '1151766000', + 3 => '1', + ), + 4 => + array ( + 0 => 'Typi non habent', + 1 => ' Typi non habent claritatem insitam; est usus legentis in iis qui facit eorum claritatem.', + 2 => '1256326995', + 3 => '4', + ), + 5 => + array ( + 0 => 'Lorem ipsum', + 1 => 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat.', + 2 => '1251936720', + 3 => '1', + ), + 6 => + array ( + 0 => 'Investigationes demonstraverunt', + 1 => ' Investigationes demonstraverunt lectores legere me lius quod ii legunt saepius.', + 2 => '946702800', + 3 => '5', + ), + 7 => + array ( + 0 => 'Claritas est etiam', + 1 => ' Claritas est etiam processus dynamicus, qui sequitur mutationem consuetudium lectorum.', + 2 => '438112720', + 3 => '6', + ), + 8 => + array ( + 0 => 'Mirum est notare', + 1 => ' Mirum est notare quam littera gothica, quam nunc putamus parum claram, anteposuerit litterarum formas humanitatis per seacula quarta decima et quinta decima.', + 2 => '1151066000', + 3 => '7', + ), + 9 => + array ( + 0 => 'Eodem modo typi', + 1 => ' Eodem modo typi, qui nunc nobis videntur parum clari, fiant sollemnes in futurum.', + 2 => '1201936720', + 3 => '8', + ), +); diff --git a/sites/all/modules/feeds/tests/feeds/nodes.tsv b/sites/all/modules/feeds/tests/feeds/nodes.tsv new file mode 100644 index 0000000000000000000000000000000000000000..a3bf82fe0aad4f99d98838c7e0c1853f7ff7ed10 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds/nodes.tsv @@ -0,0 +1,10 @@ +Title Body published GUID +"Ut wisi enim ad minim veniam" "Ut wisi enim ad minim veniam quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat." 205200720 2 +"Duis autem vel eum iriure dolor" "Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi." 428112720 3 +"Nam liber tempor" "Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum." 1151766000 1 +Typi non habent"" "Typi non habent claritatem insitam; est usus legentis in iis qui facit eorum claritatem." 1256326995 4 +"Lorem ipsum" "Lorem ipsum dolor sit amet consectetuer adipiscing elit sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat." 1251936720 1 +"Investigationes demonstraverunt" "Investigationes demonstraverunt lectores legere me lius quod ii legunt saepius." 946702800 5 +"Claritas est etiam" "Claritas est etiam processus dynamicus qui sequitur mutationem consuetudium lectorum." 438112720 6 +"Mirum est notare" "Mirum est notare quam littera gothica quam nunc putamus parum claram anteposuerit litterarum formas humanitatis per seacula quarta decima et quinta decima." 1151066000 7 +"Eodem modo typi" "Eodem modo typi qui nunc nobis videntur parum clari fiant sollemnes in futurum." 1201936720 8 \ No newline at end of file diff --git a/sites/all/modules/feeds/tests/feeds/nodes_changes.csv b/sites/all/modules/feeds/tests/feeds/nodes_changes.csv new file mode 100644 index 0000000000000000000000000000000000000000..797a079d75839a27103482f8fc382d8b781bfc3c --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds/nodes_changes.csv @@ -0,0 +1,10 @@ +Title,Body,published,GUID +"Ut wisi enim ad minim veniam", "CHANGE Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat.",205200720,2 +"Duis autem vel eum CHANGE iriure dolor", "Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi.",428112720,3 +"Nam liber tempor", "Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum.",1151766000,1 +Typi non habent"", "Typi CHANGE non habent claritatem insitam; est usus legentis in iis qui facit eorum claritatem.",1256326995,4 +"Lorem ipsum","Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat.",1251936720,1 +"Investigationes demonstraverunt", "Investigationes demonstraverunt lectores legere me lius quod ii legunt saepius.",946702800,5 +"Claritas CHANGE est etiam", "Claritas est etiam processus dynamicus, qui sequitur mutationem consuetudium lectorum.",438112720,6 +"Mirum est notare", "Mirum est notare quam littera gothica, quam nunc putamus parum claram, anteposuerit litterarum formas humanitatis per seacula quarta decima et quinta decima.",1151066000,7 +"Eodem modo typi", "Eodem modo typi, qui nunc nobis videntur parum clari, fiant sollemnes in futurum.",1201936720,8 \ No newline at end of file diff --git a/sites/all/modules/feeds/tests/feeds/path_alias.csv b/sites/all/modules/feeds/tests/feeds/path_alias.csv new file mode 100644 index 0000000000000000000000000000000000000000..a3b8ac2bab4f51e26aedadd00a50e43794a5603b --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds/path_alias.csv @@ -0,0 +1,10 @@ +Title,GUID,path +pathauto1,1,path1 +pathauto2,2,path2 +pathauto3,3,path3 +pathauto4,4,path4 +pathauto5,5,path5 +pathauto6,6,path6 +pathauto7,7,path7 +pathauto8,8,path8 +pathauto9,9,path9 diff --git a/sites/all/modules/feeds/tests/feeds/profile.csv b/sites/all/modules/feeds/tests/feeds/profile.csv new file mode 100644 index 0000000000000000000000000000000000000000..1e3ab6c65f3e8374e550caabcedb4da2b6914eae --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds/profile.csv @@ -0,0 +1,3 @@ +name,mail,color,letter +magna,auctor@tortor.com,red,alpha +rhoncus,rhoncus@habitasse.org,blue,beta diff --git a/sites/all/modules/feeds/tests/feeds/sitemap-example.xml b/sites/all/modules/feeds/tests/feeds/sitemap-example.xml new file mode 100644 index 0000000000000000000000000000000000000000..7695060cc4e47c3a31a6e8f472ea150ec2959197 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds/sitemap-example.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="UTF-8"?> +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + <url> + <loc>http://www.example.com/</loc> + <lastmod>2005-01-01</lastmod> + <changefreq>monthly</changefreq> + <priority>0.8</priority> + </url> + <url> + <loc>http://www.example.com/catalog?item=12&desc=vacation_hawaii</loc> + <changefreq>weekly</changefreq> + </url> + <url> + <loc>http://www.example.com/catalog?item=73&desc=vacation_new_zealand</loc> + <lastmod>2004-12-23</lastmod> + <changefreq>weekly</changefreq> + </url> + <url> + <loc>http://www.example.com/catalog?item=74&desc=vacation_newfoundland</loc> + <lastmod>2004-12-23T18:00:15+00:00</lastmod> + <priority>0.3</priority> + </url> + <url> + <loc>http://www.example.com/catalog?item=83&desc=vacation_usa</loc> + <lastmod>2004-11-23</lastmod> + </url> +</urlset> diff --git a/sites/all/modules/feeds/tests/feeds/users.csv b/sites/all/modules/feeds/tests/feeds/users.csv new file mode 100644 index 0000000000000000000000000000000000000000..aede81cabb8c729f8717f9b617296c46acea5073 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds/users.csv @@ -0,0 +1,6 @@ +name,mail,since,password +Morticia,morticia@example.com,1244347500,mort +Fester,fester@example.com,1241865600,fest +Gomez,gomez@example.com,1228572000,gome +Wednesday,wednesdayexample.com,1228347137,wedn +Pugsley,pugsley@example,1228260225,pugs diff --git a/sites/all/modules/feeds/tests/feeds_date_time.test b/sites/all/modules/feeds/tests/feeds_date_time.test new file mode 100644 index 0000000000000000000000000000000000000000..a4494581dd91e4e16e8c8e32ae6497784ff04cde --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds_date_time.test @@ -0,0 +1,42 @@ +<?php + +/** + * @file + * Tests for FeedsDateTime class. + */ + +/** + * Test FeedsDateTime class. + * + * Using DrupalWebTestCase as DrupalUnitTestCase is broken in SimpleTest 2.8. + * Not inheriting from Feeds base class as ParserCSV should be moved out of + * Feeds at some time. + */ +class FeedsDateTimeTest extends FeedsWebTestCase { + protected $profile = 'testing'; + + public static function getInfo() { + return array( + 'name' => 'FeedsDateTime unit tests', + 'description' => 'Unit tests for Feeds date handling.', + 'group' => 'Feeds', + ); + } + + public function setUp() { + parent::setUp(); + module_load_include('inc', 'feeds' , 'plugins/FeedsParser'); + } + + /** + * Dispatch tests, only use one entry point method testX to save time. + */ + public function test() { + $date = new FeedsDateTime('2010-20-12'); + $this->assertTrue(is_numeric($date->format('U'))); + $date = new FeedsDateTime('created'); + $this->assertTrue(is_numeric($date->format('U'))); + $date = new FeedsDateTime('12/3/2009 20:00:10'); + $this->assertTrue(is_numeric($date->format('U'))); + } +} diff --git a/sites/all/modules/feeds/tests/feeds_fetcher_file.test b/sites/all/modules/feeds/tests/feeds_fetcher_file.test new file mode 100644 index 0000000000000000000000000000000000000000..6431698ff0ec92e0590d88742ec4d07e6fe42e32 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds_fetcher_file.test @@ -0,0 +1,70 @@ +<?php + +/** + * @file + * File fetcher tests. + */ + +/** + * File fetcher test class. + */ +class FeedsFileFetcherTestCase extends FeedsWebTestCase { + public static function getInfo() { + return array( + 'name' => 'File fetcher', + 'description' => 'Tests for file fetcher plugin.', + 'group' => 'Feeds', + ); + } + + + /** + * Test scheduling on cron. + */ + public function test() { + // Set up an importer. + $this->createImporterConfiguration('Node import', 'node'); + // Set and configure plugins and mappings. + $edit = array( + 'content_type' => '', + ); + $this->drupalPost('admin/structure/feeds/node/settings', $edit, 'Save'); + $this->setPlugin('node', 'FeedsFileFetcher'); + $this->setPlugin('node', 'FeedsCSVParser'); + $mappings = array( + '0' => array( + 'source' => 'title', + 'target' => 'title', + ), + ); + $this->addMappings('node', $mappings); + // Straight up upload is covered in other tests, focus on direct mode + // and file batching here. + $this->setSettings('node', 'FeedsFileFetcher', array('direct' => TRUE)); + + // Verify that invalid paths are not accepted. + foreach (array('private://', '/tmp/') as $path) { + $edit = array( + 'feeds[FeedsFileFetcher][source]' => $path, + ); + $this->drupalPost('import/node', $edit, t('Import')); + $this->assertText("File needs to reside within the site's file directory, its path needs to start with public://."); + $count = db_query("SELECT COUNT(*) FROM {feeds_source} WHERE feed_nid = 0")->fetchField(); + $this->assertEqual($count, 0); + } + + // Verify batching through directories. + // Copy directory of files. + $dir = 'public://batchtest'; + $this->copyDir($this->absolutePath() . '/tests/feeds/batch', $dir); + + // Ingest directory of files. Set limit to 5 to force processor to batch, + // too. + variable_set('feeds_process_limit', 5); + $edit = array( + 'feeds[FeedsFileFetcher][source]' => $dir, + ); + $this->drupalPost('import/node', $edit, t('Import')); + $this->assertText('Created 18 nodes'); + } +} diff --git a/sites/all/modules/feeds/tests/feeds_mapper.test b/sites/all/modules/feeds/tests/feeds_mapper.test new file mode 100644 index 0000000000000000000000000000000000000000..a570dfae6cea34fd02cf6c5640b51079e0595e0f --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds_mapper.test @@ -0,0 +1,160 @@ +<?php + +/** + * @file + * Helper class with auxiliary functions for feeds mapper module tests. + */ + +/** + * Base class for implementing Feeds Mapper test cases. + */ +class FeedsMapperTestCase extends FeedsWebTestCase { + + // A lookup map to select the widget for each field type. + private static $field_widgets = array( + 'date' => 'date_text', + 'datestamp' => 'date_text', + 'datetime' => 'date_text', + 'number_decimal' => 'number', + 'email' => 'email_textfield', + 'emimage' => 'emimage_textfields', + 'emaudio' => 'emaudio_textfields', + 'filefield' => 'filefield_widget', + 'image' => 'imagefield_widget', + 'link_field' => 'link_field', + 'number_float' => 'number', + 'number_integer' => 'number', + 'nodereference' => 'nodereference_select', + 'text' => 'text_textfield', + 'userreference' => 'userreference_select', + ); + + /** + * Assert that a form field for the given field with the given value + * exists in the current form. + * + * @param $field_name + * The name of the field. + * @param $value + * The (raw) value expected for the field. + * @param $index + * The index of the field (for q multi-valued field). + * + * @see FeedsMapperTestCase::getFormFieldsNames() + * @see FeedsMapperTestCase::getFormFieldsValues() + */ + protected function assertNodeFieldValue($field_name, $value, $index = 0) { + $names = $this->getFormFieldsNames($field_name, $index); + $values = $this->getFormFieldsValues($field_name, $value); + foreach ($names as $k => $name) { + $value = $values[$k]; + $this->assertFieldByName($name, $value, t('Found form field %name for %field_name with the expected value.', array('%name' => $name, '%field_name' => $field_name))); + } + } + + /** + * Returns the form fields names for a given CCK field. Default implementation + * provides support for a single form field with the following name pattern + * <code>"field_{$field_name}[{$index}][value]"</code> + * + * @param $field_name + * The name of the CCK field. + * @param $index + * The index of the field (for q multi-valued field). + * + * @return + * An array of form field names. + */ + protected function getFormFieldsNames($field_name, $index) { + return array("field_{$field_name}[und][{$index}][value]"); + } + + /** + * Returns the form fields values for a given CCK field. Default implementation + * returns a single element array with $value casted to a string. + * + * @param $field_name + * The name of the CCK field. + * @param $value + * The (raw) value expected for the CCK field. + * @return An array of form field values. + */ + protected function getFormFieldsValues($field_name, $value) { + return array((string)$value); + } + + /** + * Create a new content-type, and add a field to it. Mostly copied from + * cck/tests/content.crud.test ContentUICrud::testAddFieldUI + * + * @param $settings + * (Optional) An array of settings to pass through to + * drupalCreateContentType(). + * @param $fields + * (Optional) an keyed array of $field_name => $field_type used to add additional + * fields to the new content type. + * + * @return + * The machine name of the new content type. + * + * @see DrupalWebTestCase::drupalCreateContentType() + */ + final protected function createContentType(array $settings = array(), array $fields = array()) { + $type = $this->drupalCreateContentType($settings); + $typename = $type->type; + + $admin_type_url = 'admin/structure/types/manage/' . str_replace('_', '-', $typename); + + // Create the fields + foreach ($fields as $field_name => $options) { + if (is_string($options)) { + $options = array('type' => $options); + } + $field_type = isset($options['type']) ? $options['type'] : 'text'; + $field_widget = isset($options['widget']) ? $options['widget'] : $this->selectFieldWidget($field_name, $field_type); + $this->assertTrue($field_widget !== NULL, "Field type $field_type supported"); + $label = $field_name . '_' . $field_type . '_label'; + $edit = array( + 'fields[_add_new_field][label]' => $label, + 'fields[_add_new_field][field_name]' => $field_name, + 'fields[_add_new_field][type]' => $field_type, + 'fields[_add_new_field][widget_type]' => $field_widget, + ); + $this->drupalPost($admin_type_url . '/fields', $edit, 'Save'); + + // (Default) Configure the field. + $edit = isset($options['settings']) ? $options['settings'] : array(); + $this->drupalPost(NULL, $edit, 'Save field settings'); + $this->assertText('Updated field ' . $label . ' field settings.'); + + $edit = isset($options['instance_settings']) ? $options['instance_settings'] : array(); + $this->drupalPost(NULL, $edit, 'Save settings'); + $this->assertText('Saved ' . $label . ' configuration.'); + } + + return $typename; + } + + /** + * Select the widget for the field. Default implementation provides widgets + * for Date, Number, Text, Node reference, User reference, Email, Emfield, + * Filefield, Image, and Link. + * + * Extracted as a method to allow test implementations to add widgets for + * the tested CCK field type(s). $field_name allow to test the same + * field type with different widget (is this useful ?) + * + * @param $field_name + * The name of the field. + * @param $field_type + * The CCK type of the field. + * + * @return + * The widget for this field, or NULL if the field_type is not + * supported by this test class. + */ + protected function selectFieldWidget($field_name, $field_type) { + $field_widgets = FeedsMapperTestCase::$field_widgets; + return isset($field_widgets[$field_type]) ? $field_widgets[$field_type] : NULL; + } +} diff --git a/sites/all/modules/feeds/tests/feeds_mapper_config.test b/sites/all/modules/feeds/tests/feeds_mapper_config.test new file mode 100644 index 0000000000000000000000000000000000000000..b5fd92ddd694b49f599fbfc9d7a022a387f77a67 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds_mapper_config.test @@ -0,0 +1,115 @@ +<?php + +/** + * @file + * Test cases for Feeds mapping configuration form. + */ + +/** + * Class for testing basic Feeds ajax mapping configurtaion form behavior. + */ +class FeedsMapperConfigTestCase extends FeedsMapperTestCase { + public static function getInfo() { + return array( + 'name' => 'Mapper: Config', + 'description' => 'Test the mapper configuration UI.', + 'group' => 'Feeds', + ); + } + + public function setUp() { + parent::setUp(array('feeds_tests')); + } + + /** + * Basic test of mapping configuration. + */ + public function test() { + // Create importer configuration. + $this->createImporterConfiguration(); + $this->addMappings('syndication', array( + array( + 'source' => 'url', + 'target' => 'test_target', + ), + )); + + // Click gear to get form. + $this->drupalPostAJAX(NULL, array(), 'mapping_settings_edit_0'); + + // Set some settings. + $edit = array( + 'config[0][settings][checkbox]' => 1, + 'config[0][settings][textfield]' => 'Some text', + 'config[0][settings][textarea]' => 'Textarea value: Didery dofffffffffffffffffffffffffffffffffffff', + 'config[0][settings][radios]' => 'option1', + 'config[0][settings][select]' => 'option4', + ); + $this->drupalPostAJAX(NULL, $edit, 'mapping_settings_update_0'); + + // Click Save. + $this->drupalPost(NULL, array(), t('Save')); + + // Reload. + $this->drupalGet('admin/structure/feeds/syndication/mapping'); + + // See if our settings were saved. + $this->assertText('Checkbox active.'); + $this->assertText('Textfield value: Some text'); + $this->assertText('Textarea value: Didery dofffffffffffffffffffffffffffffffffffff'); + $this->assertText('Radios value: Option 1'); + $this->assertText('Select value: Another One'); + + // Check that settings are in db. + $config = unserialize(db_query("SELECT config FROM {feeds_importer} WHERE id='syndication'")->fetchField()); + + $settings = $config['processor']['config']['mappings'][0]; + $this->assertEqual($settings['checkbox'], 1); + $this->assertEqual($settings['textfield'], 'Some text'); + $this->assertEqual($settings['textarea'], 'Textarea value: Didery dofffffffffffffffffffffffffffffffffffff'); + $this->assertEqual($settings['radios'], 'option1'); + $this->assertEqual($settings['select'], 'option4'); + + + // Check that form validation works. + // Click gear to get form. + $this->drupalPostAJAX(NULL, array(), 'mapping_settings_edit_0'); + + // Set some settings. + $edit = array( + // Required form item. + 'config[0][settings][textfield]' => '', + ); + $this->drupalPostAJAX(NULL, $edit, 'mapping_settings_update_0'); + $this->assertText('A text field field is required.'); + $this->drupalPost(NULL, array(), t('Save')); + // Reload. + $this->drupalGet('admin/structure/feeds/syndication/mapping'); + // Value has not changed. + $this->assertText('Textfield value: Some text'); + + // Check that multiple mappings work. + $this->addMappings('syndication', array( + 1 => array( + 'source' => 'url', + 'target' => 'test_target', + ), + )); + $this->assertText('Checkbox active.'); + $this->assertText('Checkbox inactive.'); + // Click gear to get form. + $this->drupalPostAJAX(NULL, array(), 'mapping_settings_edit_1'); + // Set some settings. + $edit = array( + 'config[1][settings][textfield]' => 'Second mapping text', + ); + $this->drupalPostAJAX(NULL, $edit, 'mapping_settings_update_1'); + // Click Save. + $this->drupalPost(NULL, array(), t('Save')); + // Reload. + $this->drupalGet('admin/structure/feeds/syndication/mapping'); + $this->assertText('Checkbox active.'); + $this->assertText('Checkbox inactive.'); + $this->assertText('Second mapping text'); + } +} diff --git a/sites/all/modules/feeds/tests/feeds_mapper_date.test b/sites/all/modules/feeds/tests/feeds_mapper_date.test new file mode 100644 index 0000000000000000000000000000000000000000..60fb383cdf7130060d651edb94eb6b1013955e27 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds_mapper_date.test @@ -0,0 +1,100 @@ +<?php + +/** + * @file + * Test case for CCK date field mapper mappers/date.inc. + */ + +/** + * Class for testing Feeds <em>content</em> mapper. + * + * @todo: Add test method iCal + * @todo: Add test method for end date + */ +class FeedsMapperDateTestCase extends FeedsMapperTestCase { + public static function getInfo() { + return array( + 'name' => 'Mapper: Date', + 'description' => 'Test Feeds Mapper support for CCK Date fields.', + 'group' => 'Feeds', + 'dependencies' => array('date'), + ); + } + + public function setUp() { + parent::setUp(array('date_api', 'date')); + variable_set('date_default_timezone', 'UTC'); + } + + /** + * Basic test loading a single entry CSV file. + */ + public function test() { + $this->drupalGet('admin/config/regional/settings'); + + // Create content type. + $typename = $this->createContentType(array(), array( + 'date' => 'date', + 'datestamp' => 'datestamp', + //'datetime' => 'datetime', // REMOVED because the field is broken ATM. + )); + + // Create and configure importer. + $this->createImporterConfiguration('Date RSS', 'daterss'); + $this->setSettings('daterss', NULL, array('content_type' => '', 'import_period' => FEEDS_SCHEDULE_NEVER)); + $this->setPlugin('daterss', 'FeedsFileFetcher'); + $this->setPlugin('daterss', 'FeedsSyndicationParser'); + $this->setSettings('daterss', 'FeedsNodeProcessor', array('content_type' => $typename)); + $this->addMappings('daterss', array( + 0 => array( + 'source' => 'title', + 'target' => 'title', + ), + 1 => array( + 'source' => 'description', + 'target' => 'body', + ), + 2 => array( + 'source' => 'timestamp', + 'target' => 'field_date:start', + ), + 3 => array( + 'source' => 'timestamp', + 'target' => 'field_datestamp:start', + ), + )); + + $edit = array( + 'allowed_extensions' => 'rss2', + ); + $this->drupalPost('admin/structure/feeds/daterss/settings/FeedsFileFetcher', $edit, 'Save'); + + // Import CSV file. + $this->importFile('daterss', $this->absolutePath() . '/tests/feeds/googlenewstz.rss2'); + $this->assertText('Created 6 nodes'); + + // Check the imported nodes. + $values = array( + '01/06/2010 - 19:26', + '01/06/2010 - 10:21', + '01/06/2010 - 13:42', + '01/06/2010 - 06:05', + '01/06/2010 - 11:26', + '01/07/2010 - 00:26', + ); + for ($i = 1; $i <= 6; $i++) { + $this->drupalGet("node/$i/edit"); + $this->assertNodeFieldValue('date', $values[$i-1]); + $this->assertNodeFieldValue('datestamp', $values[$i-1]); + } + } + + protected function getFormFieldsNames($field_name, $index) { + if (in_array($field_name, array('date', 'datetime', 'datestamp'))) { + return array("field_{$field_name}[und][{$index}][value][date]"); + } + else { + return parent::getFormFieldsNames($field_name, $index); + } + } +} diff --git a/sites/all/modules/feeds/tests/feeds_mapper_field.test b/sites/all/modules/feeds/tests/feeds_mapper_field.test new file mode 100644 index 0000000000000000000000000000000000000000..130d0af3de806c3a6223b406d26657cec8813d24 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds_mapper_field.test @@ -0,0 +1,90 @@ +<?php + +/** + * @file + * Test case for simple CCK field mapper mappers/content.inc. + */ + +/** + * Class for testing Feeds field mapper. + */ +class FeedsMapperFieldTestCase extends FeedsMapperTestCase { + public static function getInfo() { + return array( + 'name' => 'Mapper: Fields', + 'description' => 'Test Feeds Mapper support for fields.', + 'group' => 'Feeds', + ); + } + + public function setUp() { + parent::setUp(array('number')); + } + + /** + * Basic test loading a double entry CSV file. + */ + function test() { + // Create content type. + $typename = $this->createContentType(array(), array( + 'alpha' => 'text', + 'beta' => 'number_integer', + 'gamma' => 'number_decimal', + 'delta' => 'number_float', + )); + + // Create and configure importer. + $this->createImporterConfiguration('Content CSV', 'csv'); + $this->setSettings('csv', NULL, array('content_type' => '', 'import_period' => FEEDS_SCHEDULE_NEVER)); + $this->setPlugin('csv', 'FeedsFileFetcher'); + $this->setPlugin('csv', 'FeedsCSVParser'); + $this->setSettings('csv', 'FeedsNodeProcessor', array('content_type' => $typename)); + $this->addMappings('csv', array( + 0 => array( + 'source' => 'title', + 'target' => 'title', + ), + 1 => array( + 'source' => 'created', + 'target' => 'created', + ), + 2 => array( + 'source' => 'body', + 'target' => 'body', + ), + 3 => array( + 'source' => 'alpha', + 'target' => 'field_alpha', + ), + 4 => array( + 'source' => 'beta', + 'target' => 'field_beta', + ), + 5 => array( + 'source' => 'gamma', + 'target' => 'field_gamma', + ), + 6 => array( + 'source' => 'delta', + 'target' => 'field_delta', + ), + )); + + // Import CSV file. + $this->importFile('csv', $this->absolutePath() . '/tests/feeds/content.csv'); + $this->assertText('Created 2 nodes'); + + // Check the two imported files. + $this->drupalGet('node/1/edit'); + $this->assertNodeFieldValue('alpha', 'Lorem'); + $this->assertNodeFieldValue('beta', '42'); + $this->assertNodeFieldValue('gamma', '4.20'); + $this->assertNodeFieldValue('delta', '3.14159'); + + $this->drupalGet('node/2/edit'); + $this->assertNodeFieldValue('alpha', 'Ut wisi'); + $this->assertNodeFieldValue('beta', '32'); + $this->assertNodeFieldValue('gamma', '1.20'); + $this->assertNodeFieldValue('delta', '5.62951'); + } +} diff --git a/sites/all/modules/feeds/tests/feeds_mapper_file.test b/sites/all/modules/feeds/tests/feeds_mapper_file.test new file mode 100644 index 0000000000000000000000000000000000000000..909e47ed8c5d4f9379c41c385a9616f62ae83b72 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds_mapper_file.test @@ -0,0 +1,180 @@ +<?php + +/** + * @file + * Test case for Filefield mapper mappers/filefield.inc. + */ + +/** + * Class for testing Feeds file mapper. + * + * @todo Add a test for enclosures using a local file that is + * a) in place and that + * b) needs to be copied from one location to another. + */ +class FeedsMapperFileTestCase extends FeedsMapperTestCase { + public static function getInfo() { + return array( + 'name' => 'Mapper: File field', + 'description' => 'Test Feeds Mapper support for file fields. <strong>Requires SimplePie library</strong>.', + 'group' => 'Feeds', + ); + } + + /** + * Basic test loading a single entry CSV file. + */ + public function test() { + // If this is unset (or FALSE) http_request.inc will use curl, and will generate a 404 + // for this feel url provided by feeds_tests. However, if feeds_tests was enabled in your + // site before running the test, it will work fine. Since it is truly screwy, lets just + // force it to use drupal_http_request for this test case. + variable_set('feeds_never_use_curl', TRUE); + variable_set('clean_url', TRUE); + + // Only download simplepie if the plugin doesn't already exist somewhere. + // People running tests locally might have it. + if (!feeds_simplepie_exists()) { + $this->downloadExtractSimplePie('1.3'); + $this->assertTrue(feeds_simplepie_exists()); + // Reset all the caches! + $this->resetAll(); + } + $typename = $this->createContentType(array(), array('files' => 'file')); + + // 1) Test mapping remote resources to file field. + + // Create importer configuration. + $this->createImporterConfiguration(); + $this->setPlugin('syndication', 'FeedsSimplePieParser'); + $this->setSettings('syndication', 'FeedsNodeProcessor', array('content_type' => $typename)); + $this->addMappings('syndication', array( + 0 => array( + 'source' => 'title', + 'target' => 'title' + ), + 1 => array( + 'source' => 'timestamp', + 'target' => 'created' + ), + 2 => array( + 'source' => 'enclosures', + 'target' => 'field_files' + ), + )); + $nid = $this->createFeedNode('syndication', $GLOBALS['base_url'] . '/testing/feeds/flickr.xml'); + $this->assertText('Created 5 nodes'); + + $files = $this->_testFiles(); + $entities = db_select('feeds_item') + ->fields('feeds_item', array('entity_id')) + ->condition('id', 'syndication') + ->execute(); + foreach ($entities as $entity) { + $this->drupalGet('node/' . $entity->entity_id . '/edit'); + $f = new FeedsEnclosure(array_shift($files), NULL); + $this->assertText($f->getLocalValue()); + } + + // 2) Test mapping local resources to file field. + + // Copy directory of files, CSV file expects them in public://images, point + // file field to a 'resources' directory. Feeds should copy files from + // images/ to resources/ on import. + $this->copyDir($this->absolutePath() . '/tests/feeds/assets', 'public://images'); + $edit = array( + 'instance[settings][file_directory]' => 'resources', + ); + $this->drupalPost('admin/structure/types/manage/' . $typename . '/fields/field_files', $edit, t('Save settings')); + + // Create a CSV importer configuration. + $this->createImporterConfiguration('Node import from CSV', 'node'); + $this->setPlugin('node', 'FeedsCSVParser'); + $this->setSettings('node', 'FeedsNodeProcessor', array('content_type' => $typename)); + $this->addMappings('node', array( + 0 => array( + 'source' => 'title', + 'target' => 'title' + ), + 1 => array( + 'source' => 'file', + 'target' => 'field_files' + ), + )); + $edit = array( + 'content_type' => '', + ); + $this->drupalPost('admin/structure/feeds/node/settings', $edit, 'Save'); + + // Import. + $edit = array( + 'feeds[FeedsHTTPFetcher][source]' => $GLOBALS['base_url'] . '/testing/feeds/files.csv', + ); + $this->drupalPost('import/node', $edit, 'Import'); + $this->assertText('Created 5 nodes'); + + // Assert: files should be in resources/. + $files = $this->_testFiles(); + $entities = db_select('feeds_item') + ->fields('feeds_item', array('entity_id')) + ->condition('id', 'node') + ->execute(); + foreach ($entities as $entity) { + $this->drupalGet('node/' . $entity->entity_id . '/edit'); + $f = new FeedsEnclosure(array_shift($files), NULL); + $this->assertRaw('resources/' . $f->getUrlEncodedValue()); + } + + // 3) Test mapping of local resources, this time leave files in place. + $this->drupalPost('import/node/delete-items', array(), 'Delete'); + // Setting the fields file directory to images will make copying files + // obsolete. + $edit = array( + 'instance[settings][file_directory]' => 'images', + ); + $this->drupalPost('admin/structure/types/manage/' . $typename . '/fields/field_files', $edit, t('Save settings')); + $edit = array( + 'feeds[FeedsHTTPFetcher][source]' => $GLOBALS['base_url'] . '/testing/feeds/files.csv', + ); + $this->drupalPost('import/node', $edit, 'Import'); + $this->assertText('Created 5 nodes'); + + // Assert: files should be in images/ now. + $files = $this->_testFiles(); + $entities = db_select('feeds_item') + ->fields('feeds_item', array('entity_id')) + ->condition('id', 'node') + ->execute(); + foreach ($entities as $entity) { + $this->drupalGet('node/' . $entity->entity_id . '/edit'); + $f = new FeedsEnclosure(array_shift($files), NULL); + $this->assertRaw('images/' . $f->getUrlEncodedValue()); + } + + // Deleting all imported items will delete the files from the images/ dir. + // @todo: for some reason the first file does not get deleted. +// $this->drupalPost('import/node/delete-items', array(), 'Delete'); +// foreach ($this->_testFiles() as $file) { +// $this->assertFalse(is_file("public://images/$file")); +// } + } + + /** + * Lists test files. + */ + public function _testFiles() { + return array('tubing.jpeg', 'foosball.jpeg', 'attersee.jpeg', 'hstreet.jpeg', 'la fayette.jpeg'); + } + + /** + * Handle file field widgets. + */ + public function selectFieldWidget($fied_name, $field_type) { + if ($field_type == 'file') { + return 'file_generic'; + } + else { + return parent::selectFieldWidget($fied_name, $field_type); + } + } +} diff --git a/sites/all/modules/feeds/tests/feeds_mapper_link.test b/sites/all/modules/feeds/tests/feeds_mapper_link.test new file mode 100644 index 0000000000000000000000000000000000000000..5fddbb1564ab18eb8e6af3b8a60c2280544998e9 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds_mapper_link.test @@ -0,0 +1,157 @@ +<?php + +/** + * @file + * Test case for CCK link mapper mappers/date.inc. + */ + +/** + * Class for testing Feeds <em>link</em> mapper. + */ +class FeedsMapperLinkTestCase extends FeedsMapperTestCase { + public static function getInfo() { + return array( + 'name' => 'Mapper: Link', + 'description' => 'Test Feeds Mapper support for Link fields.', + 'group' => 'Feeds', + 'dependencies' => array('link'), + ); + } + + public function setUp() { + parent::setUp(array('link')); + } + + /** + * Basic test loading a single entry CSV file. + */ + public function test() { + $static_title = $this->randomName(); + + // Create content type. + $typename = $this->createContentType(array(), array( + 'alpha' => array( + 'type' => 'link_field', + 'instance_settings' => array( + 'instance[settings][title]' => 'required', + ), + ), + 'beta' => array( + 'type' => 'link_field', + 'instance_settings' => array( + 'instance[settings][title]' => 'none', + ), + ), + 'gamma' => array( + 'type' => 'link_field', + 'instance_settings' => array( + 'instance[settings][title]' => 'optional', + ), + ), + 'omega' => array( + 'type' => 'link_field', + 'instance_settings' => array( + 'instance[settings][title]' => 'value', + 'instance[settings][title_value]' => $static_title, + ), + ), + )); + + // Create importer configuration. + $this->createImporterConfiguration(); //Create a default importer configuration + $this->setSettings('syndication', 'FeedsNodeProcessor', array('content_type' => $typename)); //Processor settings + $this->addMappings('syndication', array( + 0 => array( + 'source' => 'title', + 'target' => 'title' + ), + 1 => array( + 'source' => 'timestamp', + 'target' => 'created' + ), + 2 => array( + 'source' => 'description', + 'target' => 'body' + ), + 3 => array( + 'source' => 'url', + 'target' => 'field_alpha:url' + ), + 4 => array( + 'source' => 'title', + 'target' => 'field_alpha:title' + ), + 5 => array( + 'source' => 'url', + 'target' => 'field_beta:url' + ), + 6 => array( + 'source' => 'url', + 'target' => 'field_gamma:url' + ), + 7 => array( + 'source' => 'title', + 'target' => 'field_gamma:title' + ), + 8 => array( + 'source' => 'url', + 'target' => 'field_omega:url' + ), + )); + + // Import RSS file. + $nid = $this->createFeedNode(); + // Assert 10 items aggregated after creation of the node. + $this->assertText('Created 10 nodes'); + + // Edit the imported node. + $this->drupalGet('node/2/edit'); + + $url = 'http://developmentseed.org/blog/2009/oct/06/open-atrium-translation-workflow-two-way-updating'; + $title = 'Open Atrium Translation Workflow: Two Way Translation Updates'; + $this->assertNodeFieldValue('alpha', array('url' => $url, 'static' => $title)); + $this->assertNodeFieldValue('beta', array('url' => $url)); + $this->assertNodeFieldValue('gamma', array('url' => $url, 'static' => $title)); + $this->assertNodeFieldValue('omega', array('url' => $url, 'static' => $static_title)); + + // Test the static title. + $this->drupalGet('node/2'); + $this->assertText($static_title, 'Static title link found.'); + + } + + /** + * Override parent::getFormFieldsNames(). + */ + protected function getFormFieldsNames($field_name, $index) { + if (in_array($field_name, array('alpha', 'beta', 'gamma', 'omega'))) { + $fields = array("field_{$field_name}[und][{$index}][url]"); + if (in_array($field_name, array('alpha', 'gamma'))) { + $fields[] = "field_{$field_name}[und][{$index}][title]"; + } + return $fields; + } + else { + return parent::getFormFieldsNames($field_name, $index); + } + } + + /** + * Override parent::getFormFieldsValues(). + */ + protected function getFormFieldsValues($field_name, $value) { + if (in_array($field_name, array('alpha', 'beta', 'gamma', 'omega'))) { + if (!is_array($value)) { + $value = array('url' => $value); + } + $values = array($value['url']); + if (in_array($field_name, array('alpha', 'gamma'))) { + $values[] = isset($value['title']) ? $value['title'] : ''; + } + return $values; + } + else { + return parent::getFormFieldsValues($field_name, $index); + } + } +} diff --git a/sites/all/modules/feeds/tests/feeds_mapper_path.test b/sites/all/modules/feeds/tests/feeds_mapper_path.test new file mode 100644 index 0000000000000000000000000000000000000000..55f75b021131856abe843eb9227114c328d52417 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds_mapper_path.test @@ -0,0 +1,240 @@ +<?php + +/** + * @file + * Test case for path alias mapper path.inc. + */ + +/** + * Class for testing Feeds <em>path</em> mapper. + */ +class FeedsMapperPathTestCase extends FeedsMapperTestCase { + public static function getInfo() { + return array( + 'name' => 'Mapper: Path', + 'description' => 'Test Feeds Mapper support for path aliases.', + 'group' => 'Feeds', + ); + } + + public function setUp() { + parent::setUp(array('path')); + } + + /** + * Basic test loading a single entry CSV file. + */ + public function testNodeAlias() { + + // Create importer configuration. + $this->createImporterConfiguration($this->randomName(), 'path_test'); + $this->setPlugin('path_test', 'FeedsFileFetcher'); + $this->setPlugin('path_test', 'FeedsCSVParser'); + $this->addMappings('path_test', array( + 0 => array( + 'source' => 'Title', + 'target' => 'title', + ), + 1 => array( + 'source' => 'path', + 'target' => 'path_alias', + ), + 2 => array( + 'source' => 'GUID', + 'target' => 'guid', + 'unique' => TRUE, + ), + )); + + // Turn on update existing. + $this->setSettings('path_test', 'FeedsNodeProcessor', array('update_existing' => 2)); + + // Import RSS file. + $this->importFile('path_test', $this->absolutePath() . '/tests/feeds/path_alias.csv'); + $this->assertText('Created 9 nodes'); + + $aliases = array(); + + for ($i = 1; $i <= 9; $i++) { + $aliases[] = "path$i"; + } + + $this->assertAliasCount($aliases); + + // Adding a mapping will force update. + $this->addMappings('path_test', array( + 3 => array( + 'source' => 'fake', + 'target' => 'body', + ), + )); + // Import RSS file. + $this->importFile('path_test', $this->absolutePath() . '/tests/feeds/path_alias.csv'); + $this->assertText('Updated 9 nodes'); + + // Check that duplicate aliases are not created. + $this->assertAliasCount($aliases); + } + + /** + * Test support for term aliases. + */ + public function testTermAlias() { + + // Create importer configuration. + $this->createImporterConfiguration($this->randomName(), 'path_test'); + $this->setPlugin('path_test', 'FeedsFileFetcher'); + $this->setPlugin('path_test', 'FeedsCSVParser'); + $this->setPlugin('path_test', 'FeedsTermProcessor'); + + // Create vocabulary. + $edit = array( + 'name' => 'Addams vocabulary', + 'machine_name' => 'addams', + ); + $this->drupalPost('admin/structure/taxonomy/add', $edit, t('Save')); + + $this->setSettings('path_test', 'FeedsTermProcessor', array('vocabulary' => 'addams')); + + // Turn on update existing. + $this->setSettings('path_test', 'FeedsTermProcessor', array('update_existing' => 2)); + + // Add mappings. + $this->addMappings('path_test', array( + 0 => array( + 'source' => 'Title', + 'target' => 'name', + ), + 1 => array( + 'source' => 'path', + 'target' => 'path_alias', + ), + 2 => array( + 'source' => 'GUID', + 'target' => 'guid', + 'unique' => TRUE, + ), + )); + + // Import RSS file. + $this->importFile('path_test', $this->absolutePath() . '/tests/feeds/path_alias.csv'); + $this->assertText('Created 9 terms'); + + $aliases = array(); + + for ($i = 1; $i <= 9; $i++) { + $aliases[] = "path$i"; + } + + $this->assertAliasCount($aliases); + + // Adding a mapping will force update. + $this->addMappings('path_test', array( + 3 => array( + 'source' => 'fake', + 'target' => 'description', + ), + )); + // Import RSS file. + $this->importFile('path_test', $this->absolutePath() . '/tests/feeds/path_alias.csv'); + $this->assertText('Updated 9 terms'); + + // Check that duplicate aliases are not created. + $this->assertAliasCount($aliases); + } + + public function assertAliasCount($aliases) { + $in_db = db_select('url_alias', 'a') + ->fields('a') + ->condition('a.alias', $aliases) + ->execute() + ->fetchAll(); + + $this->assertEqual(count($in_db), count($aliases), 'Correct number of aliases in db.'); + } +} + +/** + * Class for testing Feeds <em>path</em> mapper with pathauto.module. + */ +class FeedsMapperPathPathautoTestCase extends FeedsMapperTestCase { + public static function getInfo() { + return array( + 'name' => 'Mapper: Path with pathauto', + 'description' => 'Test Feeds Mapper support for path aliases and pathauto.', + 'group' => 'Feeds', + 'dependencies' => array('pathauto'), + ); + } + + public function setUp() { + parent::setUp(array('pathauto')); + } + + /** + * Basic for allowing pathauto to override the alias. + */ + public function test() { + + // Create importer configuration. + $this->createImporterConfiguration($this->randomName(), 'path_test'); + $this->setPlugin('path_test', 'FeedsFileFetcher'); + $this->setPlugin('path_test', 'FeedsCSVParser'); + $this->addMappings('path_test', array( + 0 => array( + 'source' => 'Title', + 'target' => 'title', + 'unique' => FALSE, + ), + 1 => array( + 'source' => 'does_not_exist', + 'target' => 'path_alias', + 'pathauto_override' => TRUE, + ), + 2 => array( + 'source' => 'GUID', + 'target' => 'guid', + 'unique' => TRUE, + ), + )); + + // Turn on update existing. + $this->setSettings('path_test', 'FeedsNodeProcessor', array('update_existing' => 2)); + + // Import RSS file. + $this->importFile('path_test', $this->absolutePath() . '/tests/feeds/path_alias.csv'); + $this->assertText('Created 9 nodes'); + + $aliases = array(); + + for ($i = 1; $i <= 9; $i++) { + $aliases[] = "path$i"; + } + + $this->assertAliasCount($aliases); + + // Adding a mapping will force update. + $this->addMappings('path_test', array( + 3 => array( + 'source' => 'fake', + 'target' => 'body', + ), + )); + // Import RSS file. + $this->importFile('path_test', $this->absolutePath() . '/tests/feeds/path_alias.csv'); + $this->assertText('Updated 9 nodes'); + + // Check that duplicate aliases are not created. + $this->assertAliasCount($aliases); + } + + public function assertAliasCount($aliases) { + $in_db = db_select('url_alias', 'a') + ->fields('a') + ->condition('a.alias', $aliases) + ->execute() + ->fetchAll(); + + $this->assertEqual(count($in_db), count($aliases), 'Correct number of aliases in db.'); + } +} diff --git a/sites/all/modules/feeds/tests/feeds_mapper_profile.test b/sites/all/modules/feeds/tests/feeds_mapper_profile.test new file mode 100644 index 0000000000000000000000000000000000000000..9ced5dbd8293795830d2aab6815e8402c7264707 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds_mapper_profile.test @@ -0,0 +1,102 @@ +<?php + +/** + * @file + * Test suite for profile mapper mappers/profile.inc. + */ + +/** + * Class for testing Feeds profile mapper. + */ +class FeedsMapperProfileTestCase extends FeedsMapperTestCase { + public static function getInfo() { + return array( + 'name' => 'Mapper: Profile', + 'description' => 'Test Feeds Mapper support for profile fields.', + 'group' => 'Feeds', + ); + } + + function setUp() { + // Call parent setup with required modules. + parent::setUp(array('profile')); + } + + /** + * Basic test loading a doulbe entry CSV file. + */ + function test() { + + // Create profile fields. + $edit = array( + 'category' => 'test', + 'title' => 'color', + 'name' => 'profile_textfield_test', + 'register' => 1, + ); + $name = $this->drupalPost('admin/config/people/profile/add/textfield', $edit, t('Save field')); + $edit = array( + 'category' => 'test', + 'title' => 'letter', + 'name' => 'profile_select_test', + 'options' => 'alpha' . "\n" . 'beta' . "\n" . 'gamma', + 'register' => 1, + ); + $name = $this->drupalPost('admin/config/people/profile/add/selection', $edit, t('Save field')); + + // Create an importer. + $this->createImporterConfiguration('Profile import', 'profile_import'); + + // Set and configure plugins. + $this->setPlugin('profile_import', 'FeedsFileFetcher'); + $this->setPlugin('profile_import', 'FeedsCSVParser'); + $this->setPlugin('profile_import', 'FeedsUserProcessor'); + + // Go to mapping page and create a couple of mappings. + $mappings = array( + '0' => array( + 'source' => 'name', + 'target' => 'name', + 'unique' => 0, + ), + '1' => array( + 'source' => 'mail', + 'target' => 'mail', + 'unique' => 1, + ), + '2' => array( + 'source' => 'color', + 'target' => 'profile_textfield_test', + ), + '3' => array( + 'source' => 'letter', + 'target' => 'profile_select_test', + ), + ); + $this->addMappings('profile_import', $mappings); + + // Change some of the basic configuration. + $edit = array( + 'content_type' => '', + 'import_period' => FEEDS_SCHEDULE_NEVER, + ); + $this->drupalPost('admin/structure/feeds/profile_import/settings', $edit, 'Save'); + + // Import CSV file. + $this->importFile('profile_import', $this->absolutePath() .'/tests/feeds/profile.csv'); + $this->assertText('Created 2 users.'); + + // Check the two imported users. + $this->drupalGet('admin/people'); + $this->assertText('magna'); + $this->assertText('rhoncus'); + + $account = user_load_by_name('magna'); + $this->assertEqual($account->profile_textfield_test, 'red', 'User profile_textfield_test is correct'); + $this->assertEqual($account->profile_select_test, 'alpha', 'User profile_select_test is correct'); + + $account = user_load_by_name('rhoncus'); + $this->assertEqual($account->profile_textfield_test, 'blue', 'User profile_textfield_test is correct'); + $this->assertEqual($account->profile_select_test, 'beta', 'User profile_select_test is correct'); + } +} diff --git a/sites/all/modules/feeds/tests/feeds_mapper_taxonomy.test b/sites/all/modules/feeds/tests/feeds_mapper_taxonomy.test new file mode 100644 index 0000000000000000000000000000000000000000..94dd188f58cccd21565893f969c9bbf423724215 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds_mapper_taxonomy.test @@ -0,0 +1,271 @@ +<?php + +/** + * @file + * Test case for taxonomy mapper mappers/taxonomy.inc. + */ + +/** + * Class for testing Feeds <em>content</em> mapper. + */ +class FeedsMapperTaxonomyTestCase extends FeedsMapperTestCase { + public static function getInfo() { + return array( + 'name' => 'Mapper: Taxonomy', + 'description' => 'Test Feeds Mapper support for Taxonomy.', + 'group' => 'Feeds', + ); + } + + function setUp() { + parent::setUp(); + + // Add Tags vocabulary + $edit = array( + 'name' => 'Tags', + 'machine_name' => 'tags', + ); + $this->drupalPost('admin/structure/taxonomy/add', $edit, 'Save'); + + $edit = array( + 'name' => 'term1', + ); + $this->drupalPost('admin/structure/taxonomy/tags/add', $edit, t('Save')); + $this->assertText('Created new term term1.'); + + // Create term reference field. + $field = array( + 'field_name' => 'field_tags', + 'type' => 'taxonomy_term_reference', + 'cardinality' => FIELD_CARDINALITY_UNLIMITED, + 'settings' => array( + 'allowed_values' => array( + array( + 'vocabulary' => 'tags', + 'parent' => 0, + ), + ), + ), + ); + field_create_field($field); + + // Add term reference field to feed item bundle. + $this->instance = array( + 'field_name' => 'field_tags', + 'bundle' => 'article', + 'entity_type' => 'node', + 'widget' => array( + 'type' => 'options_select', + ), + 'display' => array( + 'default' => array( + 'type' => 'taxonomy_term_reference_link', + ), + ), + ); + field_create_instance($this->instance); + + // Add term reference field to feed node bundle. + $this->instance = array( + 'field_name' => 'field_tags', + 'bundle' => 'page', + 'entity_type' => 'node', + 'widget' => array( + 'type' => 'options_select', + ), + 'display' => array( + 'default' => array( + 'type' => 'taxonomy_term_reference_link', + ), + ), + ); + field_create_instance($this->instance); + + // Create an importer configuration with basic mapping. + $this->createImporterConfiguration('Syndication', 'syndication'); + $this->addMappings('syndication', + array( + 0 => array( + 'source' => 'title', + 'target' => 'title', + ), + 1 => array( + 'source' => 'description', + 'target' => 'body', + ), + 2 => array( + 'source' => 'timestamp', + 'target' => 'created', + ), + 3 => array( + 'source' => 'url', + 'target' => 'url', + 'unique' => TRUE, + ), + 4 => array( + 'source' => 'guid', + 'target' => 'guid', + 'unique' => TRUE, + ), + ) + ); + } + + /** + * Test inheriting taxonomy from the feed node. + */ + function testInheritTaxonomy() { + + // Adjust importer settings + $this->setSettings('syndication', NULL, array('import_period' => FEEDS_SCHEDULE_NEVER)); + $this->setSettings('syndication', NULL, array('import_on_create' => FALSE)); + $this->assertText('Do not import on submission'); + + // Map feed node's taxonomy to feed item node's taxonomy. + $mappings = array( + 5 => array( + 'source' => 'parent:taxonomy:tags', + 'target' => 'field_tags', + ), + ); + $this->addMappings('syndication', $mappings); + + // Create feed node and add term term1. + $langcode = LANGUAGE_NONE; + $nid = $this->createFeedNode('syndication', NULL, 'Syndication'); + $term = taxonomy_get_term_by_name('term1'); + $term = reset($term); + $edit = array( + 'field_tags' . '[' . $langcode . '][]' => $term->tid, + ); + $this->drupalPost("node/$nid/edit", $edit, t('Save')); + $this->assertTaxonomyTerm($term->name); + + // Import nodes. + $this->drupalPost("node/$nid/import", array(), 'Import'); + $this->assertText('Created 10 nodes.'); + + $count = db_query("SELECT COUNT(*) FROM {taxonomy_index}")->fetchField(); + + // There should be one term for each node imported plus the term on the feed node. + $this->assertEqual(11, $count, 'Found correct number of tags for all feed nodes and feed items.'); + } + + /** + * Test aggregating RSS categories to taxonomy. + */ + /* + function testRSSCategoriesToTaxonomy() { + // Add mapping to tags vocabulary. + $this->addMappings('syndication', + array( + array( + 'source' => 'tags', + 'target' => 'taxonomy:1', + ), + ) + ); + + // Aggregate feed node with "Tag" vocabulary. + $nid = $this->createFeedNode(); + // Assert 10 items aggregated after creation of the node. + $this->assertText('Created 10 nodes'); + // There should be 30 terms and 44 term-node relations. + $this->assertEqual(30, db_query("SELECT count(*) FROM {term_data}")->fetchField(), "Found correct number of terms."); + $this->assertEqual(44, db_query("SELECT count(*) FROM {term_node}")->fetchField(), "Found correct number of term-node relations."); + + // Take a look at the actual terms on frontpage. + $this->drupalGet('node'); + $terms = array( + 'authentication', + 'custom mapping', + 'data visualization', + 'Drupal', + 'Drupal planet', + 'faceted search', + 'GeoDC', + 'graphs', + 'interface', + 'intranet', + 'localization', + 'localization client', + 'localization server', + 'map-basec browser', + 'mapbox', + 'microfinance', + 'MIX Market', + 'open atrium', + 'open data', + 'open source', + 'Peru', + 'salesforce', + 'siteminder', + 'siteminder module', + 'software freedom day', + 'translation', + 'translation server', + 'usability', + 'Washington DC', + 'World Bank', + ); + foreach ($terms as $term) { + $this->assertTaxonomyTerm($term); + } + + // Delete all items, all associations are gone. + $this->drupalPost("node/$nid/delete-items", array(), 'Delete'); + $this->assertText('Deleted 10 nodes'); + $this->assertEqual(30, db_query("SELECT count(*) FROM {term_data}")->fetchField(), "Found correct number of terms."); + $this->assertEqual(0, db_query("SELECT count(*) FROM {term_node}")->fetchField(), "Found correct number of term-node relations."); + + // Remove "Tag" setting, import again. + $edit = array( + 'tags' => FALSE, + ); + $this->drupalPost('admin/content/taxonomy/edit/vocabulary/1', $edit, 'Save'); + $this->drupalPost("node/$nid/import", array(), 'Import'); + $this->assertText('Created 10 nodes'); + + // We should only get one term-node association per node. + $this->assertEqual(30, db_query("SELECT count(*) FROM {term_data}")->fetchField(), "Found correct number of terms."); + $this->assertEqual(10, db_query("SELECT count(*) FROM {term_node}")->fetchField(), "Found correct number of term-node relations."); + + // Delete all items. + $this->drupalPost("node/$nid/delete-items", array(), 'Delete'); + + // Set vocabulary to multiple terms, import again. + $edit = array( + 'multiple' => TRUE, + ); + $this->drupalPost('admin/content/taxonomy/edit/vocabulary/1', $edit, 'Save'); + $this->drupalPost("node/$nid/import", array(), 'Import'); + $this->assertText('Created 10 nodes'); + + // We should get all term-node associations again. + $this->assertEqual(30, db_query("SELECT count(*) FROM {term_data}")->fetchField(), "Found correct number of terms."); + $this->assertEqual(44, db_query("SELECT count(*) FROM {term_node}")->fetchField(), "Found correct number of term-node relations."); + + // Delete all items. + $this->drupalPost("node/$nid/delete-items", array(), 'Delete'); + + // Remove a term, import again. + $this->drupalPost('admin/content/taxonomy/edit/term/1', array(), 'Delete'); + $this->drupalPost(NULL, array(), 'Delete'); + $this->assertText('Deleted term'); + $this->drupalPost("node/$nid/import", array(), 'Import'); + $this->assertText('Created 10 nodes'); + + // This term should now be missing from term-node associations. + $this->assertEqual(29, db_query("SELECT count(*) FROM {term_data}")->fetchField(), "Found correct number of terms."); + $this->assertEqual(39, db_query("SELECT count(*) FROM {term_node}")->fetchField(), "Found correct number of term-node relations."); + } + */ + + /** + * Helper, finds node style taxonomy term markup in DOM. + */ + public function assertTaxonomyTerm($term) { + $term = check_plain($term); + $this->assertPattern('/<a href="\/.*taxonomy\/term\/[0-9]+">' . $term . '<\/a>/', 'Found ' . $term); + } +} diff --git a/sites/all/modules/feeds/tests/feeds_parser_sitemap.test b/sites/all/modules/feeds/tests/feeds_parser_sitemap.test new file mode 100644 index 0000000000000000000000000000000000000000..0607d894af304288c87887e9467838d262638dd9 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds_parser_sitemap.test @@ -0,0 +1,124 @@ +<?php + +/** + * @file + * Tests for plugins/FeedsSitemapParser.inc + */ + +/** + * Test Sitemap parser. + */ +class FeedsSitemapParserTestCase extends FeedsWebTestCase { + public static function getInfo() { + return array( + 'name' => 'Sitemap parser', + 'description' => 'Regression tests for Sitemap XML format parser.', + 'group' => 'Feeds', + ); + } + + /** + * Run tests. + */ + public function test() { + $this->createImporterConfiguration('Sitemap', 'sitemap'); + $this->setPlugin('sitemap', 'FeedsSitemapParser'); + + $this->addMappings('sitemap', + array( + 0 => array( + 'source' => 'changefreq', + 'target' => 'title', + 'unique' => FALSE, + ), + 1 => array( + 'source' => 'priority', + 'target' => 'body', + 'unique' => FALSE, + ), + 2 => array( + 'source' => 'lastmod', + 'target' => 'created', + 'unique' => FALSE, + ), + 3 => array( + 'source' => 'url', + 'target' => 'url', + 'unique' => TRUE, + ), + 4 => array( + 'source' => 'url', + 'target' => 'guid', + 'unique' => TRUE, + ), + ) + ); + + + $path = $GLOBALS['base_url'] . '/' . drupal_get_path('module', 'feeds') . '/tests/feeds/'; + $nid = $this->createFeedNode('sitemap', $path . 'sitemap-example.xml', 'Testing Sitemap Parser'); + $this->assertText('Created 5 nodes'); + + // Assert DB status. + $count = db_query("SELECT COUNT(*) FROM {feeds_item} WHERE entity_type = 'node'")->fetchField(); + $this->assertEqual($count, 5, 'Accurate number of items in database.'); + + // Check items against known content of feed. + $items = db_query("SELECT * FROM {feeds_item} WHERE entity_type = 'node' AND feed_nid = :nid ORDER BY nid", array(':nid' => $nid)); + + // Check first item. + date_default_timezone_set('GMT'); + $item = $items->fetchObject(); + $node = node_load($item->nid); + $this->assertEqual($node->title, 'monthly', 'Feed item 1 changefreq is correct.'); + $this->assertEqual($node->body, '0.8', 'Feed item 1 priority is correct.'); + $this->assertEqual($node->created, strtotime('2005-01-01'), 'Feed item 1 lastmod is correct.'); + $info = feeds_item_info_load('node', $node->nid); + $this->assertEqual($info->url, 'http://www.example.com/', 'Feed item 1 url is correct.'); + $this->assertEqual($info->url, $info->guid, 'Feed item 1 guid is correct.'); + + // Check second item. + $item = $items->fetchObject(); + $node = node_load($item->nid); + $this->assertEqual($node->title, 'weekly', 'Feed item 2 changefreq is correct.'); + $this->assertEqual($node->body, '', 'Feed item 2 priority is correct.'); + // $node->created is... recently + $info = feeds_item_info_load('node', $node->nid); + $this->assertEqual($info->url, 'http://www.example.com/catalog?item=12&desc=vacation_hawaii', 'Feed item 2 url is correct.'); + $this->assertEqual($info->url, $info->guid, 'Feed item 2 guid is correct.'); + + // Check third item. + $item = $items->fetchObject(); + $node = node_load($item->nid); + $this->assertEqual($node->title, 'weekly', 'Feed item 3 changefreq is correct.'); + $this->assertEqual($node->body, '', 'Feed item 3 priority is correct.'); + $this->assertEqual($node->created, strtotime('2004-12-23'), 'Feed item 3 lastmod is correct.'); + $info = feeds_item_info_load('node', $node->nid); + $this->assertEqual($info->url, 'http://www.example.com/catalog?item=73&desc=vacation_new_zealand', 'Feed item 3 url is correct.'); + $this->assertEqual($info->url, $info->guid, 'Feed item 3 guid is correct.'); + + // Check fourth item. + $item = $items->fetchObject(); + $node = node_load($item->nid); + $this->assertEqual($node->title, '', 'Feed item 4 changefreq is correct.'); + $this->assertEqual($node->body, '0.3', 'Feed item 4 priority is correct.'); + $this->assertEqual($node->created, strtotime('2004-12-23T18:00:15+00:00'), 'Feed item 4 lastmod is correct.'); + $info = feeds_item_info_load('node', $node->nid); + $this->assertEqual($info->url, 'http://www.example.com/catalog?item=74&desc=vacation_newfoundland', 'Feed item 4 url is correct.'); + $this->assertEqual($info->url, $info->guid, 'Feed item 1 guid is correct.'); + + // Check fifth item. + $item = $items->fetchObject(); + $node = node_load($item->nid); + $this->assertEqual($node->title, '', 'Feed item 5 changefreq is correct.'); + $this->assertEqual($node->body, '', 'Feed item 5 priority is correct.'); + $this->assertEqual($node->created, strtotime('2004-11-23'), 'Feed item 5 lastmod is correct.'); + $info = feeds_item_info_load('node', $node->nid); + $this->assertEqual($info->url, 'http://www.example.com/catalog?item=83&desc=vacation_usa', 'Feed item 5 url is correct.'); + $this->assertEqual($info->url, $info->guid, 'Feed item 5 guid is correct.'); + + // Check for more items. + $item = $items->fetchObject(); + $this->assertFalse($item, 'Correct number of feed items recorded.'); + } +} diff --git a/sites/all/modules/feeds/tests/feeds_parser_syndication.test b/sites/all/modules/feeds/tests/feeds_parser_syndication.test new file mode 100644 index 0000000000000000000000000000000000000000..c4ea234fc0a2bf2dd7dd6a69ebc5761140f55e2c --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds_parser_syndication.test @@ -0,0 +1,57 @@ +<?php + +/** + * @file + * Tests for plugins/FeedsSyndicationParser.inc. + */ + +/** + * Test single feeds. + */ +class FeedsSyndicationParserTestCase extends FeedsWebTestCase { + public static function getInfo() { + return array( + 'name' => 'Syndication parsers', + 'description' => 'Regression tests for syndication parsers Common syndication and SimplePie. Tests parsers against a set of feeds in the context of Feeds module. <strong>Requires SimplePie parser to be configured correctly.</strong>', + 'group' => 'Feeds', + 'dependencies' => array('libraries'), + ); + } + + /** + * Set up test. + */ + public function setUp() { + parent::setUp(array('libraries')); + } + + /** + * Run tests. + */ + public function test() { + $this->createImporterConfiguration('Syndication', 'syndication'); + + foreach (array('FeedsSyndicationParser', 'FeedsSimplePieParser') as $parser) { + $this->setPlugin('syndication', $parser); + foreach ($this->feedUrls() as $url => $assertions) { + $this->createFeedNode('syndication', $url); + $this->assertText('Created ' . $assertions['item_count'] . ' nodes'); + } + } + } + + /** + * Return an array of test feeds. + */ + protected function feedUrls() { + $path = $GLOBALS['base_url'] . '/' . drupal_get_path('module', 'feeds') . '/tests/feeds/'; + return array( + "{$path}developmentseed.rss2" => array( + 'item_count' => 10, + ), + "{$path}feed_without_guid.rss2" => array( + 'item_count' => 10, + ), + ); + } +} diff --git a/sites/all/modules/feeds/tests/feeds_processor_node.test b/sites/all/modules/feeds/tests/feeds_processor_node.test new file mode 100644 index 0000000000000000000000000000000000000000..677bd95a826c4099b1c4ea04e013881da651ed06 --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds_processor_node.test @@ -0,0 +1,458 @@ +<?php + +/** + * @file + * Tests for plugins/FeedsNodeProcessor.inc. + */ + +/** + * Test aggregating a feed as node items. + */ +class FeedsRSStoNodesTest extends FeedsWebTestCase { + + public static function getInfo() { + return array( + 'name' => 'RSS import to nodes', + 'description' => 'Tests a feed configuration that is attached to a content type, uses HTTP fetcher, common syndication parser and a node processor. Repeats the same test for an importer configuration that is not attached to a content type and for a configuration that is attached to a content type and uses the file fetcher.', + 'group' => 'Feeds', + ); + } + + /** + * Set up test. + */ + public function setUp() { + parent::setUp(); + + // Set the front page to show 20 nodes so we can easily see what is aggregated. + variable_set('default_nodes_main', 20); + + // Set the teaser length display to unlimited otherwise tests looking for + // text on nodes will fail. + $edit = array('fields[body][type]' => 'text_default'); + $this->drupalPost('admin/structure/types/manage/article/display/teaser', $edit, 'Save'); + + // Create an importer configuration. + $this->createImporterConfiguration('Syndication', 'syndication'); + $this->addMappings('syndication', + array( + 0 => array( + 'source' => 'title', + 'target' => 'title', + 'unique' => FALSE, + ), + 1 => array( + 'source' => 'description', + 'target' => 'body', + ), + 2 => array( + 'source' => 'timestamp', + 'target' => 'created', + ), + 3 => array( + 'source' => 'url', + 'target' => 'url', + 'unique' => TRUE, + ), + 4 => array( + 'source' => 'guid', + 'target' => 'guid', + 'unique' => TRUE, + ), + ) + ); + } + + /** + * Test node creation, refreshing/deleting feeds and feed items. + */ + public function test() { + $nid = $this->createFeedNode(); + + // Assert 10 items aggregated after creation of the node. + $this->assertText('Created 10 nodes'); + $article_nid = db_query_range("SELECT nid FROM {node} WHERE type = 'article'", 0, 1)->fetchField(); + $this->assertEqual("Created by FeedsNodeProcessor", db_query("SELECT nr.log FROM {node} n JOIN {node_revision} nr ON n.vid = nr.vid WHERE n.nid = :nid", array(':nid' => $article_nid))->fetchField()); + + // Navigate to feed node, there should be Feeds tabs visible. + $this->drupalGet("node/$nid"); + $this->assertRaw("node/$nid/import"); + $this->assertRaw("node/$nid/delete-items"); + + // Assert accuracy of aggregated information. + $this->drupalGet('node'); + $this->assertRaw('<span class="username">Anonymous (not verified)</span>'); + $this->assertDevseedFeedContent(); + + // Assert DB status. + $count = db_query("SELECT COUNT(*) FROM {node} n INNER JOIN {feeds_item} fi ON fi.entity_type = 'node' AND n.nid = fi.entity_id")->fetchField(); + $this->assertEqual($count, 10, 'Accurate number of items in database.'); + + // Assert default input format on first imported feed node. + + // NEEDS update. + // $format = db_query_range("SELECT nr.format FROM {feeds_node_item} fi JOIN {node} n ON fi.nid = n.nid JOIN {node_revision} nr ON n.vid = nr.vid", 0, 1)->fetchField(); + // $this->assertEqual($format, filter_fallback_format(), 'Using default Input format.'); + + // Import again. + $this->drupalPost("node/$nid/import", array(), 'Import'); + $this->assertText('There are no new nodes'); + + // Assert DB status, there still shouldn't be more than 10 items. + $count = db_query("SELECT COUNT(*) FROM {node} n INNER JOIN {feeds_item} fi ON fi.entity_type = 'node' AND n.nid = fi.entity_id")->fetchField(); + $this->assertEqual($count, 10, 'Accurate number of items in database.'); + + // All of the above tests should have produced published nodes, set default + // to unpublished, import again. + $count = db_query("SELECT COUNT(*) FROM {node} n INNER JOIN {feeds_item} fi ON fi.entity_type = 'node' AND n.nid = fi.entity_id WHERE n.status = 1")->fetchField(); + $this->assertEqual($count, 10, 'All items are published.'); + $edit = array( + 'node_options[status]' => FALSE, + ); + $this->drupalPost('admin/structure/types/manage/article', $edit, t('Save content type')); + $this->drupalPost("node/$nid/delete-items", array(), 'Delete'); + $this->drupalPost("node/$nid/import", array(), 'Import'); + $count = db_query("SELECT COUNT(*) FROM {node} n INNER JOIN {feeds_item} fi ON fi.entity_type = 'node' AND n.nid = fi.entity_id WHERE n.status = 0")->fetchField(); + $this->assertEqual($count, 10, 'No items are published.'); + $edit = array( + 'node_options[status]' => TRUE, + ); + $this->drupalPost('admin/structure/types/manage/article', $edit, t('Save content type')); + $this->drupalPost("node/$nid/delete-items", array(), 'Delete'); + + // Enable replace existing and import updated feed file. + $this->drupalPost("node/$nid/import", array(), 'Import'); + $this->setSettings('syndication', 'FeedsNodeProcessor', array('update_existing' => 1)); + $feed_url = $GLOBALS['base_url'] . '/' . drupal_get_path('module', 'feeds') . '/tests/feeds/developmentseed_changes.rss2'; + $this->editFeedNode($nid, $feed_url); + $this->drupalPost("node/$nid/import", array(), 'Import'); + $this->assertText('Updated 2 nodes'); + + // Assert accuracy of aggregated content (check 2 updates, one original). + $this->drupalGet('node'); + $this->assertText('Managing News Translation Workflow: Two Way Translation Updates'); + $this->assertText('Presenting on Features in Drupal and Managing News'); + $this->assertText('Scaling the Open Atrium UI'); + + // Import again. + $this->drupalPost("node/$nid/import", array(), 'Import'); + $this->assertText('There are no new nodes'); + $this->assertFeedItemCount(10); + + // Now delete all items. + $this->drupalPost("node/$nid/delete-items", array(), 'Delete'); + $this->assertText('Deleted 10 nodes'); + $this->assertFeedItemCount(0); + + // Change author and turn off authorization. + $this->auth_user = $this->drupalCreateUser(array('access content')); + $this->setSettings('syndication', 'FeedsNodeProcessor', array('author' => $this->auth_user->name, 'authorize' => FALSE)); + + // Change input format. + $this->setSettings('syndication', 'FeedsNodeProcessor', array('input_format' => 'plain_text')); + + // Import again. + $this->drupalPost("node/$nid/import", array(), 'Import'); + $this->assertText('Created 10 nodes'); + + // Assert author. + $this->drupalGet('node'); + $this->assertPattern('/<span class="username">' . check_plain($this->auth_user->name) . '<\/span>/'); + $count = db_query("SELECT COUNT(*) FROM {feeds_item} fi JOIN {node} n ON fi.entity_type = 'node' AND fi.entity_id = n.nid WHERE n.uid = :uid", array(':uid' => $this->auth_user->uid))->fetchField(); + $this->assertEqual($count, 10, 'Accurate number of items in database.'); + + // Assert input format. + + // NEEDS update. + // $format = db_query_range("SELECT nr.format FROM {feeds_node_item} fi JOIN {node} n ON fi.nid = n.nid JOIN {node_revision} nr ON n.vid = nr.vid", 0, 1)->fetchField(); + // $this->assertEqual($format, filter_fallback_format() + 1, 'Set non-default Input format.'); + + // Set to update existing, remove authorship of above nodes and import again. + $this->setSettings('syndication', 'FeedsNodeProcessor', array('update_existing' => 2)); + $nids = db_query("SELECT nid FROM {node} n INNER JOIN {feeds_item} fi ON fi.entity_type = 'node' AND n.nid = fi.entity_id")->fetchCol(); + db_update('node') + ->fields(array('uid' => 0)) + ->condition('nid', $nids, 'IN') + ->execute(); + db_update('feeds_item') + ->fields(array('hash' => '')) + ->condition('entity_type', 'node') + ->condition('entity_id', $nids, 'IN') + ->execute(); + $this->drupalPost("node/$nid/import", array(), 'Import'); + $this->drupalGet('node'); + $this->assertNoPattern('/<span class="username">' . check_plain($this->auth_user->name) . '<\/span>/'); + $count = db_query("SELECT COUNT(*) FROM {feeds_item} fi JOIN {node} n ON fi.entity_type = 'node' AND fi.entity_id = n.nid WHERE n.uid = :uid", array(':uid' => $this->auth_user->uid))->fetchField(); + $this->assertEqual($count, 0, 'Accurate number of items in database.'); + + // Map feed node's author to feed item author, update - feed node's items + // should now be assigned to feed node author. + $this->addMappings('syndication', + array( + 5 => array( + 'source' => 'parent:uid', + 'target' => 'uid', + ), + ) + ); + $this->drupalPost("node/$nid/import", array(), 'Import'); + $this->drupalGet('node'); + $this->assertNoPattern('/<span class="username">' . check_plain($this->auth_user->name) . '<\/span>/'); + $uid = db_query("SELECT uid FROM {node} WHERE nid = :nid", array(':nid' => $nid))->fetchField(); + $count = db_query("SELECT COUNT(*) FROM {node} WHERE uid = :uid", array(':uid' => $uid))->fetchField(); + $this->assertEqual($count, 11, 'All feed item nodes are assigned to feed node author.'); + + // Login with new user with only access content permissions. + $this->drupalLogin($this->auth_user); + + // Navigate to feed node, there should be no Feeds tabs visible. + $this->drupalGet("node/$nid"); + $this->assertNoRaw("node/$nid/import"); + $this->assertNoRaw("node/$nid/delete-items"); + + // Now create a second feed configuration that is not attached to a content + // type and run tests on importing/purging. + + // Login with sufficient permissions. + $this->drupalLogin($this->admin_user); + // Remove all items again so that next test can check for them. + $this->drupalPost("node/$nid/delete-items", array(), 'Delete'); + + // Create an importer, not attached to content type. + $this->createImporterConfiguration('Syndication standalone', 'syndication_standalone'); + $edit = array( + 'content_type' => '', + ); + $this->drupalPost('admin/structure/feeds/syndication_standalone/settings', $edit, 'Save'); + $this->addMappings('syndication_standalone', + array( + 0 => array( + 'source' => 'title', + 'target' => 'title', + 'unique' => FALSE, + ), + 1 => array( + 'source' => 'description', + 'target' => 'body', + ), + 2 => array( + 'source' => 'timestamp', + 'target' => 'created', + ), + 3 => array( + 'source' => 'url', + 'target' => 'url', + 'unique' => TRUE, + ), + 4 => array( + 'source' => 'guid', + 'target' => 'guid', + 'unique' => TRUE, + ), + ) + ); + + // Import, assert 10 items aggregated after creation of the node. + $this->importURL('syndication_standalone'); + $this->assertText('Created 10 nodes'); + + // Assert accuracy of aggregated information. + $this->drupalGet('node'); + $this->assertDevseedFeedContent(); + $this->assertFeedItemCount(10); + + // Import again. + $this->drupalPost('import/syndication_standalone', array(), 'Import'); + $this->assertText('There are no new nodes'); + $this->assertFeedItemCount(10); + + // Enable replace existing and import updated feed file. + $this->setSettings('syndication_standalone', 'FeedsNodeProcessor', array('update_existing' => 1)); + $feed_url = $GLOBALS['base_url'] . '/' . drupal_get_path('module', 'feeds') . '/tests/feeds/developmentseed_changes.rss2'; + $this->importURL('syndication_standalone', $feed_url); + $this->assertText('Updated 2 nodes'); + + // Assert accuracy of aggregated information (check 2 updates, one orig). + $this->drupalGet('node'); + $this->assertText('Managing News Translation Workflow: Two Way Translation Updates'); + $this->assertText('Presenting on Features in Drupal and Managing News'); + $this->assertText('Scaling the Open Atrium UI'); + + // Import again. + $this->drupalPost('import/syndication_standalone', array(), 'Import'); + $this->assertText('There are no new nodes'); + $this->assertFeedItemCount(10); + + // Now delete all items. + $this->drupalPost('import/syndication_standalone/delete-items', array(), 'Delete'); + $this->assertText('Deleted 10 nodes'); + $this->assertFeedItemCount(0); + + // Import again, we should find new content. + $this->drupalPost('import/syndication_standalone', array(), 'Import'); + $this->assertText('Created 10 nodes'); + $this->assertFeedItemCount(10); + + // Login with new user with only access content permissions. + $this->drupalLogin($this->auth_user); + + // Navigate to feed import form, access should be denied. + $this->drupalGet('import/syndication_standalone'); + $this->assertResponse(403); + + // Use File Fetcher. + $this->drupalLogin($this->admin_user); + + $edit = array('plugin_key' => 'FeedsFileFetcher'); + $this->drupalPost('admin/structure/feeds/syndication_standalone/fetcher', $edit, 'Save'); + + $edit = array( + 'allowed_extensions' => 'rss2', + ); + $this->drupalPost('admin/structure/feeds/syndication_standalone/settings/FeedsFileFetcher', $edit, 'Save'); + + // Create a feed node. + $edit = array( + 'files[feeds]' => $this->absolutePath() . '/tests/feeds/drupalplanet.rss2', + ); + $this->drupalPost('import/syndication_standalone', $edit, 'Import'); + $this->assertText('Created 25 nodes'); + } + + /** + * Check that the total number of entries in the feeds_item table is correct. + */ + function assertFeedItemCount($num) { + $count = db_query("SELECT COUNT(*) FROM {feeds_item} WHERE entity_type = 'node'")->fetchField(); + $this->assertEqual($count, $num, 'Accurate number of items in database.'); + } + + /** + * Check thet contents of the current page for the DS feed. + */ + function assertDevseedFeedContent() { + $this->assertText('Open Atrium Translation Workflow: Two Way Translation Updates'); + $this->assertText('Tue, 10/06/2009'); + $this->assertText('A new translation process for Open Atrium & integration with Localize Drupal'); + $this->assertText('Week in DC Tech: October 5th Edition'); + $this->assertText('Mon, 10/05/2009'); + $this->assertText('There are some great technology events happening this week'); + $this->assertText('Mapping Innovation at the World Bank with Open Atrium'); + $this->assertText('Fri, 10/02/2009'); + $this->assertText('is being used as a base platform for collaboration at the World Bank'); + $this->assertText('September GeoDC Meetup Tonight'); + $this->assertText('Wed, 09/30/2009'); + $this->assertText('Today is the last Wednesday of the month'); + $this->assertText('Week in DC Tech: September 28th Edition'); + $this->assertText('Mon, 09/28/2009'); + $this->assertText('Looking to geek out this week? There are a bunch of'); + $this->assertText('Open Data for Microfinance: The New MIXMarket.org'); + $this->assertText('Thu, 09/24/2009'); + $this->assertText('There are profiles for every country that the MIX Market is hosting.'); + $this->assertText('Integrating the Siteminder Access System in an Open Atrium-based Intranet'); + $this->assertText('Tue, 09/22/2009'); + $this->assertText('In addition to authentication, the Siteminder system'); + $this->assertText('Week in DC Tech: September 21 Edition'); + $this->assertText('Mon, 09/21/2009'); + $this->assertText('an interesting variety of technology events happening in Washington, DC '); + $this->assertText('s Software Freedom Day: Impressions & Photos'); + $this->assertText('Mon, 09/21/2009'); + $this->assertText('Presenting on Features in Drupal and Open Atrium'); + $this->assertText('Scaling the Open Atrium UI'); + $this->assertText('Fri, 09/18/2009'); + $this->assertText('The first major change is switching'); + } + + /** + * Test validation of feed URLs. + */ + function testFeedURLValidation() { + $edit['feeds[FeedsHTTPFetcher][source]'] = 'invalid://url'; + $this->drupalPost('node/add/page', $edit, 'Save'); + $this->assertText('The URL invalid://url is invalid.'); + } + + /** + * Test using non-normal URLs like feed:// and webcal://. + */ + function testOddFeedSchemes() { + $url = $GLOBALS['base_url'] . '/' . drupal_get_path('module', 'feeds') . '/tests/feeds/developmentseed.rss2'; + + $schemes = array('feed', 'webcal'); + $item_count = 0; + foreach ($schemes as $scheme) { + $feed_url = strtr($url, array('http://' => $scheme . '://', 'https://' => $scheme . '://')); + + $edit['feeds[FeedsHTTPFetcher][source]'] = $feed_url; + $this->drupalPost('node/add/page', $edit, 'Save'); + $this->assertText('Basic page Development Seed - Technological Solutions for Progressive Organizations has been created.'); + $this->assertText('Created 10 nodes.'); + $this->assertFeedItemCount($item_count + 10); + $item_count += 10; + } + } + + /** + * Test that feed elements and links are not found on non-feed nodes. + */ + function testNonFeedNodeUI() { + // There should not be feed links on an article node. + $non_feed_node = $this->drupalCreateNode(array('type' => 'article')); + $this->drupalGet('node/' . $non_feed_node->nid); + $this->assertNoLinkByHref('node/' . $non_feed_node->nid . '/import'); + $this->assertNoLink('Delete items'); + + // Navigate to a non-feed node form, there should be no Feed field visible. + $this->drupalGet('node/add/article'); + $this->assertNoFieldByName('feeds[FeedsHTTPFetcher][source]'); + } + + /** + * Test that nodes will not be created if the user is unauthorized to create + * them. + */ + public function testAuthorize() { + + // Create a user with limited permissions. We can't use + // $this->drupalCreateUser here because we need to to set a specific user + // name. + $edit = array( + 'name' => 'Development Seed', + 'mail' => 'devseed@example.com', + 'pass' => user_password(), + 'status' => 1, + ); + + $account = user_save(drupal_anonymous_user(), $edit); + + // Adding a mapping to the user_name will invoke authorization. + $this->addMappings('syndication', + array( + 5 => array( + 'source' => 'author_name', + 'target' => 'user_name', + ), + ) + ); + + $nid = $this->createFeedNode(); + + $this->assertText('Failed importing 10 nodes.'); + $this->assertText('User ' . $account->name . ' is not authorized to create content type article.'); + $node_count = db_query("SELECT COUNT(*) FROM {node}")->fetchField(); + + // We should have 1 node, the feed node. + $this->assertEqual($node_count, 1, t('Correct number of nodes in the database.')); + + // Give the user our admin powers. + $edit = array( + 'roles' => $this->admin_user->roles, + ); + $account = user_save($account, $edit); + + $this->drupalPost("node/$nid/import", array(), 'Import'); + $this->assertText('Created 10 nodes.'); + $node_count = db_query("SELECT COUNT(*) FROM {node}")->fetchField(); + $this->assertEqual($node_count, 11, t('Correct number of nodes in the database.')); + } +} diff --git a/sites/all/modules/feeds/tests/feeds_processor_term.test b/sites/all/modules/feeds/tests/feeds_processor_term.test new file mode 100644 index 0000000000000000000000000000000000000000..4adcd0804dd621127447c304add06cb1397c0cfa --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds_processor_term.test @@ -0,0 +1,92 @@ +<?php + +/** + * @file + * Tests for plugins/FeedsTermProcessor.inc + */ + +/** + * Test aggregating a feed as data records. + */ +class FeedsCSVtoTermsTest extends FeedsWebTestCase { + public static function getInfo() { + return array( + 'name' => 'CSV import to taxonomy', + 'description' => 'Tests a standalone import configuration that uses file fetcher and CSV parser to import taxonomy terms from a CSV file.', + 'group' => 'Feeds', + ); + } + + /** + * Test node creation, refreshing/deleting feeds and feed items. + */ + public function test() { + + // Create an importer. + $this->createImporterConfiguration('Term import', 'term_import'); + + // Set and configure plugins and mappings. + $this->setPlugin('term_import', 'FeedsFileFetcher'); + $this->setPlugin('term_import', 'FeedsCSVParser'); + $this->setPlugin('term_import', 'FeedsTermProcessor'); + $mappings = array( + 0 => array( + 'source' => 'name', + 'target' => 'name', + 'unique' => 1, + ), + ); + $this->addMappings('term_import', $mappings); + + // Use standalone form. + $edit = array( + 'content_type' => '', + ); + $this->drupalPost('admin/structure/feeds/term_import/settings', $edit, 'Save'); + + $edit = array( + 'name' => 'Addams vocabulary', + 'machine_name' => 'addams', + ); + $this->drupalPost('admin/structure/taxonomy/add', $edit, t('Save')); + + $edit = array( + 'vocabulary' => 'addams', + ); + $this->drupalPost('admin/structure/feeds/term_import/settings/FeedsTermProcessor', $edit, t('Save')); + + // Import and assert. + $this->importFile('term_import', $this->absolutePath() . '/tests/feeds/users.csv'); + $this->assertText('Created 5 terms'); + $this->drupalGet('admin/structure/taxonomy/addams'); + $this->assertText('Morticia'); + $this->assertText('Fester'); + $this->assertText('Gomez'); + $this->assertText('Pugsley'); + + // Import again. + $this->importFile('term_import', $this->absolutePath() . '/tests/feeds/users.csv'); + $this->assertText('There are no new terms.'); + + // Force update. + $this->setSettings('term_import', 'FeedsTermProcessor', array( + 'skip_hash_check' => TRUE, + 'update_existing' => 2, + )); + $this->importFile('term_import', $this->absolutePath() . '/tests/feeds/users.csv'); + $this->assertText('Updated 5 terms.'); + + // Add a term manually, delete all terms, this term should still stand. + $edit = array( + 'name' => 'Cousin Itt', + ); + $this->drupalPost('admin/structure/taxonomy/addams/add', $edit, t('Save')); + $this->drupalPost('import/term_import/delete-items', array(), t('Delete')); + $this->drupalGet('admin/structure/taxonomy/addams'); + $this->assertText('Cousin Itt'); + $this->assertNoText('Morticia'); + $this->assertNoText('Fester'); + $this->assertNoText('Gomez'); + $this->assertNoText('Pugsley'); + } +} diff --git a/sites/all/modules/feeds/tests/feeds_processor_user.test b/sites/all/modules/feeds/tests/feeds_processor_user.test new file mode 100644 index 0000000000000000000000000000000000000000..73f8d06d6d08b030580e8788017e9f7b2c87617d --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds_processor_user.test @@ -0,0 +1,122 @@ +<?php + +/** + * @file + * Tests for plugins/FeedsUserProcessor.inc + */ + +/** + * Test aggregating a feed as data records. + */ +class FeedsCSVtoUsersTest extends FeedsWebTestCase { + public static function getInfo() { + return array( + 'name' => 'CSV import to users', + 'description' => 'Tests a standalone import configuration that uses file fetcher and CSV parser to import users from a CSV file.', + 'group' => 'Feeds', + ); + } + + /** + * Test node creation, refreshing/deleting feeds and feed items. + */ + public function test() { + // Create an importer. + $this->createImporterConfiguration('User import', 'user_import'); + + // Set and configure plugins. + $this->setPlugin('user_import', 'FeedsFileFetcher'); + $this->setPlugin('user_import', 'FeedsCSVParser'); + $this->setPlugin('user_import', 'FeedsUserProcessor'); + + // Go to mapping page and create a couple of mappings. + $mappings = array( + 0 => array( + 'source' => 'name', + 'target' => 'name', + 'unique' => FALSE, + ), + 1 => array( + 'source' => 'mail', + 'target' => 'mail', + 'unique' => TRUE, + ), + 2 => array( + 'source' => 'since', + 'target' => 'created', + ), + 3 => array( + 'source' => 'password', + 'target' => 'pass', + ), + ); + $this->addMappings('user_import', $mappings); + + // Use standalone form. + $edit = array( + 'content_type' => '', + ); + $this->drupalPost('admin/structure/feeds/user_import/settings', $edit, 'Save'); + + // Create roles and assign one of them to the users to be imported. + $manager_rid = $this->drupalCreateRole(array('access content'), 'manager'); + $admin_rid = $this->drupalCreateRole(array('access content'), 'administrator'); + $edit = array( + "roles[$manager_rid]" => TRUE, + "roles[$admin_rid]" => FALSE, + ); + $this->setSettings('user_import', 'FeedsUserProcessor', $edit); + + // Import CSV file. + $this->importFile('user_import', $this->absolutePath() . '/tests/feeds/users.csv'); + + // Assert result. + $this->assertText('Created 3 users'); + // 1 user has an invalid email address, all users should be assigned + // the manager role. + $this->assertText('Failed importing 2 users.'); + $this->drupalGet('admin/people'); + $this->assertText('Morticia'); + $this->assertText('Fester'); + $this->assertText('Gomez'); + $count = db_query("SELECT count(*) FROM {users_roles} WHERE rid = :rid", array(':rid' => $manager_rid))->fetchField(); + $this->assertEqual($count, 3, t('All imported users were assigned the manager role.')); + $count = db_query("SELECT count(*) FROM {users_roles} WHERE rid = :rid", array(':rid' => $admin_rid))->fetchField(); + $this->assertEqual($count, 0, t('No imported user was assigned the administrator role.')); + + // Run import again, verify no new users. + $this->importFile('user_import', $this->absolutePath() . '/tests/feeds/users.csv'); + $this->assertText('Failed importing 2 users.'); + + // Attempt to log in as one of the imported users. + $account = user_load_by_name('Morticia'); + $this->assertTrue($account, 'Imported user account loaded.'); + $account->pass_raw = 'mort'; + $this->drupalLogin($account); + + // Login as admin. + $this->drupalLogin($this->admin_user); + + // Removing a mapping forces updating without needing a different file. + // We are also testing that if we don't map anything to the user's password + // that it will keep its existing one. + $mappings = array( + 3 => array( + 'source' => 'password', + 'target' => 'pass', + ), + ); + $this->removeMappings('user_import', $mappings); + $this->setSettings('user_import', 'FeedsUserProcessor', array('update_existing' => 2)); + $this->importFile('user_import', $this->absolutePath() . '/tests/feeds/users.csv'); + // Assert result. + $this->assertText('Updated 3 users'); + $this->assertText('Failed importing 2 user'); + + // Attempt to log in as one of the imported users. + $account = user_load_by_name('Fester'); + $this->assertTrue($account, 'Imported user account loaded.'); + $account->pass_raw = 'fest'; + $this->drupalLogin($account); + } +} diff --git a/sites/all/modules/feeds/tests/feeds_scheduler.test b/sites/all/modules/feeds/tests/feeds_scheduler.test new file mode 100644 index 0000000000000000000000000000000000000000..861e8c99d5a87e85993ec9e558a40e736bf3c68b --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds_scheduler.test @@ -0,0 +1,258 @@ +<?php + +/** + * @file + * Feeds tests. + */ + +/** + * Test cron scheduling. + */ +class FeedsSchedulerTestCase extends FeedsWebTestCase { + + public static function getInfo() { + return array( + 'name' => 'Scheduler', + 'description' => 'Tests for feeds scheduler.', + 'group' => 'Feeds', + ); + } + + /** + * Test scheduling on cron. + */ + public function testScheduling() { + // Create importer configuration. + $this->createImporterConfiguration(); + $this->addMappings('syndication', + array( + 0 => array( + 'source' => 'title', + 'target' => 'title', + 'unique' => FALSE, + ), + 1 => array( + 'source' => 'description', + 'target' => 'body', + ), + 2 => array( + 'source' => 'timestamp', + 'target' => 'created', + ), + 3 => array( + 'source' => 'url', + 'target' => 'url', + 'unique' => TRUE, + ), + 4 => array( + 'source' => 'guid', + 'target' => 'guid', + 'unique' => TRUE, + ), + ) + ); + + // Create 10 feed nodes. Turn off import on create before doing that. + $edit = array( + 'import_on_create' => FALSE, + ); + $this->drupalPost('admin/structure/feeds/syndication/settings', $edit, 'Save'); + $this->assertText('Do not import on submission'); + + $nids = $this->createFeedNodes(); + // This implicitly tests the import_on_create node setting being 0. + $this->assertTrue($nids[0] == 1 && $nids[1] == 2, 'Node ids sequential.'); + + // Check whether feed got properly added to scheduler. + foreach ($nids as $nid) { + $this->assertEqual(1, db_query("SELECT COUNT(*) FROM {job_schedule} WHERE type = 'syndication' AND id = :nid AND name = 'feeds_source_import' AND last <> 0 AND scheduled = 0 AND period = 1800 AND periodic = 1", array(':nid' => $nid))->fetchField()); + } + + // Take time for comparisons. + $time = time(); + sleep(1); + + // Log out and run cron, no changes. + $this->drupalLogout(); + $this->cronRun(); + $count = db_query("SELECT COUNT(*) FROM {job_schedule} WHERE last > :time", array(':time' => $time))->fetchField(); + $this->assertEqual($count, 0, '0 feeds refreshed on cron.'); + + // Set next time to 0 to simulate updates. + // There should be 2 x job_schedule_num (= 10) feeds updated now. + db_query("UPDATE {job_schedule} SET next = 0"); + $this->cronRun(); + $this->cronRun(); + + // There should be feeds_schedule_num X 2 (= 20) feeds updated now. + $schedule = array(); + $rows = db_query("SELECT id, last, scheduled FROM {job_schedule} WHERE last > :time", array(':time' => $time)); + foreach ($rows as $row) { + $schedule[$row->id] = $row; + } + $this->assertEqual(count($schedule), 20, '20 feeds refreshed on cron.' . $count); + + // There should be 200 article nodes in the database. + $count = db_query("SELECT COUNT(*) FROM {node} WHERE type = 'article' AND status = 1")->fetchField(); + $this->assertEqual($count, 200, 'There are 200 article nodes aggregated.' . $count); + + // There shouldn't be any items with scheduled = 1 now, if so, this would + // mean they are stuck. + $count = db_query("SELECT COUNT(*) FROM {job_schedule} WHERE scheduled = 1")->fetchField(); + $this->assertEqual($count, 0, 'All items are unscheduled (schedule flag = 0).' . $count); + + // Hit cron again twice. + $this->cronRun(); + $this->cronRun(); + + // The import_period setting of the feed configuration is 1800, there + // shouldn't be any change to the database now. + $equal = TRUE; + $rows = db_query("SELECT id, last, scheduled FROM {job_schedule} WHERE last > :time", array(':time' => $time)); + foreach ($rows as $row) { + $equal = $equal && ($row->last == $schedule[$row->id]->last); + } + $this->assertTrue($equal, 'Schedule did not change.'); + + // Log back in and set refreshing to as often as possible. + $this->drupalLogin($this->admin_user); + $edit = array( + 'import_period' => 0, + ); + $this->drupalPost('admin/structure/feeds/syndication/settings', $edit, 'Save'); + $this->assertText('Periodic import: as often as possible'); + $this->drupalLogout(); + + // Hit cron once, this should cause Feeds to reschedule all entries. + $this->cronRun(); + $equal = FALSE; + $rows = db_query("SELECT id, last, scheduled FROM {job_schedule} WHERE last > :time", array(':time' => $time)); + foreach ($rows as $row) { + $equal = $equal && ($row->last == $schedule[$row->id]->last); + $schedule[$row->id] = $row; + } + $this->assertFalse($equal, 'Every feed schedule time changed.'); + + // Hit cron again, 4 times now, every item should change again. + for ($i = 0; $i < 4; $i++) { + $this->cronRun(); + } + $equal = FALSE; + $rows = db_query("SELECT id, last, scheduled FROM {job_schedule} WHERE last > :time", array(':time' => $time)); + foreach ($rows as $row) { + $equal = $equal && ($row->last == $schedule[$row->id]->last); + } + $this->assertFalse($equal, 'Every feed schedule time changed.'); + + // There should be 200 article nodes in the database. + $count = db_query("SELECT COUNT(*) FROM {node} WHERE type = 'article' AND status = 1")->fetchField(); + $this->assertEqual($count, 200, 'The total of 200 article nodes has not changed.'); + + // Set expire settings, check rescheduling. + $max_last = db_query("SELECT MAX(last) FROM {job_schedule} WHERE type = 'syndication' AND name = 'feeds_source_import' AND period = 0")->fetchField(); + $min_last = db_query("SELECT MIN(last) FROM {job_schedule} WHERE type = 'syndication' AND name = 'feeds_source_import' AND period = 0")->fetchField(); + $this->assertEqual(0, db_query("SELECT COUNT(*) FROM {job_schedule} WHERE type = 'syndication' AND name = 'feeds_importer_expire' AND last <> 0 AND scheduled = 0")->fetchField()); + $this->drupalLogin($this->admin_user); + $this->setSettings('syndication', 'FeedsNodeProcessor', array('expire' => 86400)); + $this->drupalLogout(); + sleep(1); + $this->cronRun(); + // There should be a feeds_importer_expire job now, and all last fields should be reset. + $this->assertEqual(1, db_query("SELECT COUNT(*) FROM {job_schedule} WHERE type = 'syndication' AND name = 'feeds_importer_expire' AND last <> 0 AND scheduled = 0 AND period = 3600")->fetchField()); + $new_max_last = db_query("SELECT MAX(last) FROM {job_schedule} WHERE type = 'syndication' AND name = 'feeds_source_import' AND period = 0")->fetchField(); + $new_min_last = db_query("SELECT MIN(last) FROM {job_schedule} WHERE type = 'syndication' AND name = 'feeds_source_import' AND period = 0")->fetchField(); + $this->assertNotEqual($new_max_last, $max_last); + $this->assertNotEqual($new_min_last, $min_last); + $this->assertEqual($new_max_last, $new_min_last); + $max_last = $new_max_last; + $min_last = $new_min_last; + + // Set import settings, check rescheduling. + $this->drupalLogin($this->admin_user); + $this->setSettings('syndication', '', array('import_period' => 3600)); + $this->drupalLogout(); + sleep(1); + $this->cronRun(); + $new_max_last = db_query("SELECT MAX(last) FROM {job_schedule} WHERE type = 'syndication' AND name = 'feeds_source_import' AND period = 3600")->fetchField(); + $new_min_last = db_query("SELECT MIN(last) FROM {job_schedule} WHERE type = 'syndication' AND name = 'feeds_source_import' AND period = 3600")->fetchField(); + $this->assertNotEqual($new_max_last, $max_last); + $this->assertNotEqual($new_min_last, $min_last); + $this->assertEqual($new_max_last, $new_min_last); + $this->assertEqual(0, db_query("SELECT COUNT(*) FROM {job_schedule} WHERE type = 'syndication' AND name = 'feeds_source_import' AND period <> 3600")->fetchField()); + $this->assertEqual(1, db_query("SELECT COUNT(*) FROM {job_schedule} WHERE type = 'syndication' AND name = 'feeds_importer_expire' AND period = 3600 AND last = :last", array(':last' => $new_min_last))->fetchField()); + + // Delete source, delete importer, check schedule. + $this->drupalLogin($this->admin_user); + $nid = array_shift($nids); + $this->drupalPost("node/$nid/delete", array(), t('Delete')); + $this->assertEqual(0, db_query("SELECT COUNT(*) FROM {job_schedule} WHERE type = 'syndication' AND name = 'feeds_source_import' AND id = :nid", array(':nid' => $nid))->fetchField()); + $this->assertEqual(count($nids), db_query("SELECT COUNT(*) FROM {job_schedule} WHERE type = 'syndication' AND name = 'feeds_source_import'")->fetchField()); + $this->assertEqual(1, db_query("SELECT COUNT(*) FROM {job_schedule} WHERE type = 'syndication' AND name = 'feeds_importer_expire' AND id = 0")->fetchField()); + + $this->drupalPost('admin/structure/feeds/syndication/delete', array(), t('Delete')); + $this->assertEqual(0, db_query("SELECT COUNT(*) FROM {job_schedule} WHERE type = 'syndication' AND name = 'feeds_importer_expire' AND id = 0")->fetchField()); + $this->assertEqual(count($nids), db_query("SELECT COUNT(*) FROM {job_schedule} WHERE type = 'syndication' AND name = 'feeds_source_import'")->fetchField()); + } + + /** + * Test batching on cron. + */ + function testBatching() { + // Set up an importer. + $this->createImporterConfiguration('Node import', 'node'); + // Set and configure plugins and mappings. + $edit = array( + 'content_type' => '', + ); + $this->drupalPost('admin/structure/feeds/node/settings', $edit, 'Save'); + $this->setPlugin('node', 'FeedsFileFetcher'); + $this->setPlugin('node', 'FeedsCSVParser'); + $mappings = array( + 0 => array( + 'source' => 'title', + 'target' => 'title', + ), + ); + $this->addMappings('node', $mappings); + + // Verify that there are 86 nodes total. + $this->importFile('node', $this->absolutePath() . '/tests/feeds/many_nodes.csv'); + $this->assertText('Created 86 nodes'); + + // Run batch twice with two different process limits. + // 50 = FEEDS_PROCESS_LIMIT. + foreach (array(10, 50) as $limit) { + variable_set('feeds_process_limit', $limit); + + db_query("UPDATE {job_schedule} SET next = 0"); + $this->drupalPost('import/node/delete-items', array(), 'Delete'); + $this->assertEqual(0, db_query("SELECT COUNT(*) FROM {node} WHERE type = 'article'")->fetchField()); + + // Hit cron (item count / limit) times, assert correct number of articles. + for ($i = 0; $i < ceil(86 / $limit); $i++) { + $this->cronRun(); + sleep(1); + if ($limit * ($i + 1) < 86) { + $count = $limit * ($i + 1); + $period = 0; // Import should be rescheduled for ASAP. + } + else { + $count = 86; // We've reached our total of 86. + $period = 1800; // Hence we should find the Source's default period. + } + $this->assertEqual($count, db_query("SELECT COUNT(*) FROM {node} WHERE type = 'article'")->fetchField()); + $this->assertEqual($period, db_query("SELECT period FROM {job_schedule} WHERE type = 'node' AND id = 0")->fetchField()); + } + } + + // Delete a couple of nodes, then hit cron again. They should not be replaced + // as the minimum update time is 30 minutes. + $nodes = db_query_range("SELECT nid FROM {node} WHERE type = 'article'", 0, 2); + foreach ($nodes as $node) { + $this->drupalPost("node/{$node->nid}/delete", array(), 'Delete'); + } + $this->assertEqual(84, db_query("SELECT COUNT(*) FROM {node} WHERE type = 'article'")->fetchField()); + $this->cronRun(); + $this->assertEqual(84, db_query("SELECT COUNT(*) FROM {node} WHERE type = 'article'")->fetchField()); + } +} diff --git a/sites/all/modules/feeds/tests/feeds_tests.info b/sites/all/modules/feeds/tests/feeds_tests.info new file mode 100644 index 0000000000000000000000000000000000000000..6373229c7221776d782eb14f20e46b0d5dd61cef --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds_tests.info @@ -0,0 +1,14 @@ +name = "Feeds module tests" +description = "Support module for Feeds related testing." +package = Testing +version = VERSION +core = 7.x +files[] = feeds_test.module +hidden = TRUE + +; Information added by drupal.org packaging script on 2012-10-24 +version = "7.x-2.0-alpha7" +core = "7.x" +project = "feeds" +datestamp = "1351111319" + diff --git a/sites/all/modules/feeds/tests/feeds_tests.module b/sites/all/modules/feeds/tests/feeds_tests.module new file mode 100644 index 0000000000000000000000000000000000000000..ade38b2ea396087e9bd8ac9e43cf81d38e6be37e --- /dev/null +++ b/sites/all/modules/feeds/tests/feeds_tests.module @@ -0,0 +1,184 @@ +<?php + +/** + * @file + * Helper module for Feeds tests. + */ + +/** + * Implements hook_menu(). + */ +function feeds_tests_menu() { + $items['testing/feeds/flickr.xml'] = array( + 'page callback' => 'feeds_tests_flickr', + 'access arguments' => array('access content'), + 'type' => MENU_CALLBACK, + ); + $items['testing/feeds/files.csv'] = array( + 'page callback' => 'feeds_tests_files', + 'access arguments' => array('access content'), + 'type' => MENU_CALLBACK, + ); + return $items; +} + +/** + * Implements hook_theme(). + */ +function feeds_tests_theme() { + return array( + 'feeds_tests_flickr' => array( + 'variables' => array('image_urls' => array()), + 'path' => drupal_get_path('module', 'feeds_tests') . '/feeds', + 'template' => 'feeds-tests-flickr', + ), + 'feeds_tests_files' => array( + 'variables' => array('files' => array()), + 'path' => drupal_get_path('module', 'feeds_tests') . '/feeds', + 'template' => 'feeds-tests-files', + ), + ); +} + +/** + * Outputs flickr test feed. + */ +function feeds_tests_flickr() { + $images = array( + 0 => "tubing.jpeg", + 1 => "foosball.jpeg", + 2 => "attersee.jpeg", + 3 => "hstreet.jpeg", + 4 => "la fayette.jpeg", + ); + $path = drupal_get_path('module', 'feeds_tests') . '/feeds/assets'; + foreach ($images as &$image) { + $image = url("$path/$image", array('absolute' => TRUE)); + } + drupal_add_http_header('Content-Type', 'application/rss+xml; charset=utf-8'); + print theme('feeds_tests_flickr', array('image_urls' => $images)); +} + +/** + * Outputs a CSV file pointing to files. + */ +function feeds_tests_files() { + $images = array( + 0 => "tubing.jpeg", + 1 => "foosball.jpeg", + 2 => "attersee.jpeg", + 3 => "hstreet.jpeg", + 4 => "la fayette.jpeg", + ); + foreach ($images as &$image) { + $image = "public://images/$image"; + } + drupal_add_http_header('Content-Type', 'text/plain; charset=utf-8'); + print theme('feeds_tests_files', array('files' => $images)); +} + +/** + * Implements hook_feeds_processor_targets_alter() + */ +function feeds_tests_feeds_processor_targets_alter(&$targets, $entity_type, $bundle_name) { + $targets['test_target'] = array( + 'name' => t('Test Target'), + 'description' => t('This is a test target.'), + 'callback' => 'feeds_tests_mapper_set_target', + 'summary_callback' => 'feeds_tests_mapper_summary', + 'form_callback' => 'feeds_tests_mapper_form', + ); +} + +/** + * Set target value on entity. + * + * @see my_module_set_target() + */ +function feeds_tests_mapper_set_target($source, $entity, $target, $value, $mapping) { + $entity->body['und'][0]['value'] = serialize($mapping); +} + +/** + * Provides setting summary for the mapper. + * + * @see my_module_summary_callback() + */ +function feeds_tests_mapper_summary($mapping, $target, $form, $form_state) { + $options = array( + 'option1' => t('Option 1'), + 'option2' => t('Another Option'), + 'option3' => t('Option for select'), + 'option4' => t('Another One') + ); + + $items = array(); + if (!empty($mapping['checkbox']) && $mapping['checkbox']) { + $items[] = t('Checkbox active.'); + } + else { + $items[] = t('Checkbox inactive.'); + } + if (!empty($mapping['textfield'])) { + $items[] = t('<strong>Textfield value</strong>: %textfield', array('%textfield' => $mapping['textfield'])); + } + if (!empty($mapping['textarea'])) { + $items[] = t('<strong>Textarea value</strong>: %textarea', array('%textarea' => $mapping['textarea'])); + } + if (!empty($mapping['radios'])) { + $items[] = t('<strong>Radios value</strong>: %radios', array('%radios' => $options[$mapping['radios']])); + } + if (!empty($mapping['select'])) { + $items[] = t('<strong>Select value</strong>: %select', array('%select' => $options[$mapping['select']])); + } + $list = array( + '#type' => 'ul', + '#theme' => 'item_list', + '#items' => $items, + ); + return drupal_render($list); +} + +/* + * Provides the form with mapper settings. + */ +function feeds_tests_mapper_form($mapping, $target, $form, $form_state) { + $mapping += array( + 'checkbox' => FALSE, + 'textfield' => '', + 'textarea' => '', + 'radios' => NULL, + 'select' => NULL, + ); + return array( + 'checkbox' => array( + '#type' => 'checkbox', + '#title' => t('A checkbox'), + '#default_value' => !empty($mapping['checkbox']), + ), + 'textfield' => array( + '#type' => 'textfield', + '#title' => t('A text field'), + '#default_value' => $mapping['textfield'], + '#required' => TRUE, + ), + 'textarea' => array( + '#type' => 'textarea', + '#title' => t('A textarea'), + '#default_value' => $mapping['textarea'], + ), + 'radios' => array( + '#type' => 'radios', + '#title' => t('Some radios'), + '#options' => array('option1' => t('Option 1'), 'option2' => t('Another Option')), + '#default_value' => $mapping['radios'], + ), + 'select' => array( + '#type' => 'select', + '#title' => t('A select list'), + '#options' => array('option3' => t('Option for select'), 'option4' => t('Another One')), + '#default_value' => $mapping['select'], + ), + ); +} + diff --git a/sites/all/modules/feeds/tests/parser_csv.test b/sites/all/modules/feeds/tests/parser_csv.test new file mode 100644 index 0000000000000000000000000000000000000000..6c8adfbf37492bf881efd229238b990049d17c28 --- /dev/null +++ b/sites/all/modules/feeds/tests/parser_csv.test @@ -0,0 +1,85 @@ +<?php + +/** + * @file + * Tests for ParserCSV library. + */ + +/** + * Test aggregating a feed as node items. + * + * Using DrupalWebTestCase as DrupalUnitTestCase is broken in SimpleTest 2.8. + * Not inheriting from Feeds base class as ParserCSV should be moved out of + * Feeds at some time. + */ +class ParserCSVTest extends DrupalWebTestCase { + protected $profile = 'testing'; + + public static function getInfo() { + return array( + 'name' => 'CSV Parser unit tests', + 'description' => 'Base level test for Feeds\' built in CSV parser.', + 'group' => 'Feeds', + ); + } + + /** + * Test method. + */ + public function test() { + drupal_load('module', 'feeds'); + feeds_include_library('ParserCSV.inc', 'ParserCSV'); + + $this->_testSimple(); + $this->_testBatching(); + } + + /** + * Simple test of parsing functionality. + */ + protected function _testSimple() { + $file = $this->absolutePath() . '/tests/feeds/nodes.csv'; + include $this->absolutePath() . '/tests/feeds/nodes.csv.php'; + + $iterator = new ParserCSVIterator($file); + $parser = new ParserCSV(); + $parser->setDelimiter(','); + $rows = $parser->parse($iterator); + $this->assertFalse($parser->lastLinePos(), t('Parser reports all lines parsed')); + $this->assertEqual(md5(serialize($rows)), md5(serialize($control_result)), t('Parsed result matches control result.')); + } + + /** + * Test batching. + */ + protected function _testBatching() { + $file = $this->absolutePath() . '/tests/feeds/nodes.csv'; + include $this->absolutePath() . '/tests/feeds/nodes.csv.php'; + + // Set up parser with 2 lines to parse per call. + $iterator = new ParserCSVIterator($file); + $parser = new ParserCSV(); + $parser->setDelimiter(','); + $parser->setLineLimit(2); + $rows = array(); + $pos = 0; + + // Call parser until all lines are parsed, then compare to control result. + do { + $parser->setStartByte($pos); + $rows = array_merge($rows, $parser->parse($iterator)); + $pos = $parser->lastLinePos(); + $this->assertTrue($parser->lastLinePos() || count($rows) == 10, t('Parser reports line limit correctly')); + } + while ($pos = $parser->lastLinePos()); + + $this->assertEqual(md5(serialize($rows)), md5(serialize($control_result)), t('Parsed result matches control result.')); + } + + /** + * Absolute path to feeds. + */ + public function absolutePath() { + return DRUPAL_ROOT . '/' . drupal_get_path('module', 'feeds'); + } +} diff --git a/sites/all/modules/feeds/views/feeds.views.inc b/sites/all/modules/feeds/views/feeds.views.inc new file mode 100644 index 0000000000000000000000000000000000000000..01ba7d4423ffc8be6bea67d3a07cab0734087529 --- /dev/null +++ b/sites/all/modules/feeds/views/feeds.views.inc @@ -0,0 +1,324 @@ +<?php + +/** + * @file + * Views integration for Feeds tables. + * + * @see http://drupal.org/project/views + */ + +/** + * Implements hook_views_data(). + */ +function feeds_views_data() { + $data = array(); + + /** + * Expose feeds_source table to views. + */ + $data['feeds_source']['table'] = array( + 'group' => 'Feeds source', + 'base' => array( + 'field' => array('feed_nid', 'id'), + 'title' => 'Feeds source', + 'help' => 'The source information for a feed, for example its URL', + ), + ); + $data['feeds_source']['feed_nid'] = array( + 'title' => 'Feed node id', + 'help' => 'Contains the node id of a feed node if the feed\'s configuration is attached to a content type, otherwise contains 0.', + 'field' => array( + 'handler' => 'views_handler_field_numeric', + 'click sortable' => TRUE, + ), + 'filter' => array( + 'handler' => 'views_handler_filter_numeric', + 'allow empty' => TRUE, + 'help' => 'Filter on a Feeds Source\'s feed_nid field.', + ), + 'argument' => array( + 'handler' => 'views_handler_argument_numeric', + 'numeric' => TRUE, + 'validate type' => 'nid', + 'help' => 'Argument on a Feeds Source\'s feed_nid field.', + ), + 'sort' => array( + 'handler' => 'views_handler_sort', + 'help' => 'Sort Feeds Source\'s feed_nid field.', + ), + ); + $data['feeds_source']['config'] = array( + 'title' => 'Source', + 'help' => 'The resource identifier for this feed. For instance a URL.', + 'field' => array( + 'handler' => 'feeds_views_handler_field_source', + 'click sortable' => FALSE, + ), + ); + $data['feeds_source']['table']['join'] = array( + 'node' => array( + 'left_field' => 'nid', + 'field' => 'feed_nid', + 'type' => 'LEFT', + ), + ); + + /** + * Expose feeds_node_item table to views. + */ + $data['feeds_item']['table'] = array( + 'group' => 'Feeds item', + ); + $data['feeds_item']['feed_nid'] = array( + 'title' => t('Owner feed nid'), + 'help' => t('The node id of the owner feed node if available.'), + 'field' => array( + 'handler' => 'views_handler_field_numeric', + 'click sortable' => TRUE, + ), + 'filter' => array( + 'handler' => 'views_handler_filter_numeric', + 'allow empty' => TRUE, + 'help' => t('Filter on Feed Items by the Feed they were generated from using the Node Id of the Feed Node.'), + ), + 'argument' => array( + 'handler' => 'views_handler_argument_numeric', + 'numeric' => TRUE, + 'validate type' => 'nid', + 'help' => t('Argument on Feed Items by the Feed they were generated from using the Node Id of the Feed Node.'), + ), + 'sort' => array( + 'help' => t('Sort Feed Items by the Feed they were generated from using the Node Id of the Feed Node.'), + ), + 'relationship' => array( + 'title' => t('Owner feed'), + 'help' => t('Relate a feed item to its owner feed node if available.'), + 'label' => t('Owner feed'), + 'base' => 'node', + 'base field' => 'nid', + ), + ); + $data['feeds_item']['url'] = array( + 'title' => t('Item URL'), + 'help' => t('Contains the URL of the feed item.'), + 'field' => array( + 'handler' => 'views_handler_field_url', + 'click sortable' => TRUE, + ), + 'filter' => array( + 'handler' => 'views_handler_filter_string', + 'allow empty' => TRUE, + 'help' => t('Filter on a Feeds Item\'s URL field.'), + ), + 'argument' => array( + 'handler' => 'views_handler_argument_string', + 'numeric' => TRUE, + 'validate type' => 'nid', + 'help' => t('Argument on a Feeds Item\'s URL field.'), + ), + 'sort' => array( + 'help' => t('Sort on a Feeds Item\'s URL field.'), + ), + ); + $data['feeds_item']['guid'] = array( + 'title' => t('Item GUID'), + 'help' => t('Contains the GUID of the feed item.'), + 'field' => array( + 'click sortable' => TRUE, + ), + 'filter' => array( + 'handler' => 'views_handler_filter_string', + 'allow empty' => TRUE, + 'help' => t('Filter on a Feeds Item\'s GUID field.'), + ), + 'argument' => array( + 'handler' => 'views_handler_argument_string', + 'numeric' => TRUE, + 'validate type' => 'nid', + 'help' => t('Argument on a Feeds Item\'s GUID field.'), + ), + 'sort' => array( + 'help' => t('Sort on a Feeds Item\'s GUID field.'), + ), + ); + $data['feeds_item']['imported'] = array( + 'title' => t('Import date'), + 'help' => t('Contains the import date of the feed item.'), + 'field' => array( + 'handler' => 'views_handler_field_date', + 'click sortable' => TRUE, + ), + 'sort' => array( + 'handler' => 'views_handler_sort_date', + 'help' => t('Sort on a Feeds Item\'s import date field.'), + ), + 'filter' => array( + 'handler' => 'views_handler_filter_date', + 'help' => t('Filter on a Feeds Item\'s import date field.'), + ), + 'argument' => array( + 'handler' => 'views_handler_argument_date', + 'numeric' => TRUE, + 'validate type' => 'nid', + 'help' => t('Argument on a Feeds Item\'s import date field.'), + ), + ); + + // Add a relationship for each entity type relating the entity's base table + // to the feeds_item table whre feeds_item.entity_type = 'entity_type'. + foreach (array('node', 'taxonomy_term', 'user') as $entity_type) { + $info = entity_get_info($entity_type); + $data['feeds_item']['table']['join'][$info['base table']] = array( + 'left_field' => $info['entity keys']['id'], + 'field' => 'entity_id', + 'type' => 'LEFT', + 'extra' => array( + array( + 'table' => 'feeds_item', + 'field' => 'entity_type', + 'value' => $entity_type, + 'operator' => '=', + ), + ), + ); + } + + /** + * Expose feeds_log table to views. + */ + $data['feeds_log']['table'] = array( + 'group' => 'Feeds log', + 'base' => array( + 'field' => array('flid'), + 'title' => 'Feeds log', + 'help' => 'Logs events during importing, clearing, expiry.', + ), + ); + $data['feeds_log']['id'] = array( + 'title' => 'Importer id', + 'help' => 'The id of an importer.', + 'field' => array( + 'handler' => 'views_handler_field', + 'click sortable' => TRUE, + ), + 'filter' => array( + 'handler' => 'views_handler_filter_string', + 'allow empty' => TRUE, + 'help' => 'Filter on an importer id.', + ), + 'argument' => array( + 'handler' => 'feeds_views_handler_argument_importer_id', + 'help' => 'Filter on an importer id.', + ), + 'sort' => array( + 'handler' => 'views_handler_sort', + 'help' => 'Sort by importer id.', + ), + 'relationship' => array( + 'title' => t('Importer'), + 'help' => t('Relate a log entry to its importer if available.'), + 'label' => t('Importer'), + 'base' => 'feeds_importer', + 'base field' => 'id', + ), + ); + $data['feeds_log']['importer_name'] = array( + 'real field' => 'id', + 'title' => 'Importer name', + 'help' => 'The human readable name of an importer.', + 'field' => array( + 'handler' => 'feeds_views_handler_field_importer_name', + ), + ); + $data['feeds_log']['feed_nid'] = array( + 'title' => 'Feed node id', + 'help' => 'Contains the node id of a feed node if the feed\'s configuration is attached to a content type, otherwise contains 0.', + 'field' => array( + 'handler' => 'views_handler_field_numeric', + 'click sortable' => TRUE, + ), + 'filter' => array( + 'handler' => 'views_handler_filter_numeric', + 'allow empty' => TRUE, + 'help' => 'Filter on a Feeds Source\'s feed_nid field.', + ), + 'argument' => array( + 'handler' => 'views_handler_argument_numeric', + 'numeric' => TRUE, + 'validate type' => 'nid', + 'help' => 'Argument on a Feeds Source\'s feed_nid field.', + ), + 'sort' => array( + 'handler' => 'views_handler_sort', + 'help' => 'Sort Feeds Source\'s feed_nid field.', + ), + 'relationship' => array( + 'title' => t('Feed node'), + 'help' => t('Relate a log entry to its feed node if available.'), + 'label' => t('Feed node'), + 'base' => 'node', + 'base field' => 'nid', + ), + ); + $data['feeds_log']['log_time'] = array( + 'title' => t('Log time'), + 'help' => t('The time of the event.'), + 'field' => array( + 'handler' => 'views_handler_field_date', + 'click sortable' => TRUE, + ), + 'sort' => array( + 'handler' => 'views_handler_sort_date', + ), + 'filter' => array( + 'handler' => 'views_handler_filter_date', + ), + ); + $data['feeds_log']['request_time'] = array( + 'title' => t('Request time'), + 'help' => t('The time of the page request of an event.'), + 'field' => array( + 'handler' => 'views_handler_field_date', + 'click sortable' => TRUE, + ), + 'sort' => array( + 'handler' => 'views_handler_sort_date', + ), + 'filter' => array( + 'handler' => 'views_handler_filter_date', + ), + ); + $data['feeds_log']['message'] = array( + 'title' => 'Log message', + 'help' => 'The message logged by the event.', + 'field' => array( + 'handler' => 'feeds_views_handler_field_log_message', + 'click sortable' => FALSE, + 'additional fields' => array( + 'variables', + ), + ), + ); + $data['feeds_log']['severity'] = array( + 'title' => 'Severity', + 'help' => 'The severity of the event logged.', + 'field' => array( + 'handler' => 'feeds_views_handler_field_severity', + 'click sortable' => FALSE, + ), + 'filter' => array( + 'handler' => 'feeds_views_handler_filter_severity', + 'allow empty' => TRUE, + 'help' => 'Filter on the severity of a log message.', + ), + ); + $data['feeds_log']['table']['join'] = array( + 'node' => array( + 'left_field' => 'nid', + 'field' => 'feed_nid', + 'type' => 'LEFT', + ), + ); + + return $data; +} diff --git a/sites/all/modules/feeds/views/feeds.views_default.inc b/sites/all/modules/feeds/views/feeds.views_default.inc new file mode 100644 index 0000000000000000000000000000000000000000..97d729848c76bf15a21e17c0c0c76709de8df7a1 --- /dev/null +++ b/sites/all/modules/feeds/views/feeds.views_default.inc @@ -0,0 +1,285 @@ +<?php + +/** + * @file + * Default view definitions for Feeds. + */ + +/** + * Implementation of hook_views_default_views(). + */ +function feeds_views_default_views() { + $views = array(); + + $view = new view; + $view->name = 'feeds_log'; + $view->description = 'Feeds log displays for overview, standalone importers and feed nodes.'; + $view->tag = 'Feeds'; + $view->base_table = 'feeds_log'; + $view->api_version = '3.0-alpha1'; + $view->disabled = FALSE; /* Edit this to true to make a default view disabled initially */ + + /* Display: Defaults */ + $handler = $view->new_display('default', 'Defaults', 'default'); + $handler->display->display_options['access']['type'] = 'perm'; + $handler->display->display_options['access']['perm'] = 'administer feeds'; + $handler->display->display_options['cache']['type'] = 'none'; + $handler->display->display_options['query']['type'] = 'views_query'; + $handler->display->display_options['exposed_form']['type'] = 'basic'; + $handler->display->display_options['pager']['type'] = 'full'; + $handler->display->display_options['pager']['options']['items_per_page'] = '50'; + $handler->display->display_options['pager']['options']['offset'] = '0'; + $handler->display->display_options['pager']['options']['id'] = '0'; + $handler->display->display_options['style_plugin'] = 'table'; + $handler->display->display_options['style_options']['columns'] = array( + 'log_time' => 'log_time', + 'request_time' => 'request_time', + 'message' => 'message', + 'severity' => 'severity', + ); + $handler->display->display_options['style_options']['default'] = '-1'; + $handler->display->display_options['style_options']['info'] = array( + 'log_time' => array( + 'sortable' => 0, + 'align' => '', + 'separator' => '', + ), + 'request_time' => array( + 'sortable' => 0, + 'align' => '', + 'separator' => '', + ), + 'message' => array( + 'align' => '', + 'separator' => '', + ), + 'severity' => array( + 'align' => '', + 'separator' => '', + ), + ); + $handler->display->display_options['style_options']['override'] = 1; + $handler->display->display_options['style_options']['sticky'] = 0; + /* Empty text: Global: Text area */ + $handler->display->display_options['empty']['area']['id'] = 'area'; + $handler->display->display_options['empty']['area']['table'] = 'views'; + $handler->display->display_options['empty']['area']['field'] = 'area'; + $handler->display->display_options['empty']['area']['empty'] = FALSE; + $handler->display->display_options['empty']['area']['content'] = 'There are no log messages.'; + $handler->display->display_options['empty']['area']['format'] = '1'; + /* Field: Feeds log: Log time */ + $handler->display->display_options['fields']['log_time']['id'] = 'log_time'; + $handler->display->display_options['fields']['log_time']['table'] = 'feeds_log'; + $handler->display->display_options['fields']['log_time']['field'] = 'log_time'; + $handler->display->display_options['fields']['log_time']['alter']['alter_text'] = 0; + $handler->display->display_options['fields']['log_time']['alter']['make_link'] = 0; + $handler->display->display_options['fields']['log_time']['alter']['absolute'] = 0; + $handler->display->display_options['fields']['log_time']['alter']['trim'] = 0; + $handler->display->display_options['fields']['log_time']['alter']['word_boundary'] = 1; + $handler->display->display_options['fields']['log_time']['alter']['ellipsis'] = 1; + $handler->display->display_options['fields']['log_time']['alter']['strip_tags'] = 0; + $handler->display->display_options['fields']['log_time']['alter']['html'] = 0; + $handler->display->display_options['fields']['log_time']['hide_empty'] = 0; + $handler->display->display_options['fields']['log_time']['empty_zero'] = 0; + $handler->display->display_options['fields']['log_time']['date_format'] = 'custom'; + $handler->display->display_options['fields']['log_time']['custom_date_format'] = 'Y-m-d H:i:s'; + /* Field: Feeds log: Request time */ + $handler->display->display_options['fields']['request_time']['id'] = 'request_time'; + $handler->display->display_options['fields']['request_time']['table'] = 'feeds_log'; + $handler->display->display_options['fields']['request_time']['field'] = 'request_time'; + $handler->display->display_options['fields']['request_time']['alter']['alter_text'] = 0; + $handler->display->display_options['fields']['request_time']['alter']['make_link'] = 0; + $handler->display->display_options['fields']['request_time']['alter']['absolute'] = 0; + $handler->display->display_options['fields']['request_time']['alter']['trim'] = 0; + $handler->display->display_options['fields']['request_time']['alter']['word_boundary'] = 1; + $handler->display->display_options['fields']['request_time']['alter']['ellipsis'] = 1; + $handler->display->display_options['fields']['request_time']['alter']['strip_tags'] = 0; + $handler->display->display_options['fields']['request_time']['alter']['html'] = 0; + $handler->display->display_options['fields']['request_time']['hide_empty'] = 0; + $handler->display->display_options['fields']['request_time']['empty_zero'] = 0; + $handler->display->display_options['fields']['request_time']['date_format'] = 'custom'; + $handler->display->display_options['fields']['request_time']['custom_date_format'] = 'Y-m-d H:i:s'; + /* Field: Feeds log: Log message */ + $handler->display->display_options['fields']['message']['id'] = 'message'; + $handler->display->display_options['fields']['message']['table'] = 'feeds_log'; + $handler->display->display_options['fields']['message']['field'] = 'message'; + $handler->display->display_options['fields']['message']['label'] = 'Message'; + $handler->display->display_options['fields']['message']['hide_empty'] = 0; + $handler->display->display_options['fields']['message']['empty_zero'] = 0; + /* Field: Feeds log: Severity */ + $handler->display->display_options['fields']['severity']['id'] = 'severity'; + $handler->display->display_options['fields']['severity']['table'] = 'feeds_log'; + $handler->display->display_options['fields']['severity']['field'] = 'severity'; + $handler->display->display_options['fields']['severity']['hide_empty'] = 0; + $handler->display->display_options['fields']['severity']['empty_zero'] = 0; + /* Sort criterion: Feeds log: Log time */ + $handler->display->display_options['sorts']['log_time']['id'] = 'log_time'; + $handler->display->display_options['sorts']['log_time']['table'] = 'feeds_log'; + $handler->display->display_options['sorts']['log_time']['field'] = 'log_time'; + $handler->display->display_options['sorts']['log_time']['order'] = 'DESC'; + /* Argument: Feeds log: Importer id */ + $handler->display->display_options['arguments']['id']['id'] = 'id'; + $handler->display->display_options['arguments']['id']['table'] = 'feeds_log'; + $handler->display->display_options['arguments']['id']['field'] = 'id'; + $handler->display->display_options['arguments']['id']['default_action'] = 'empty'; + $handler->display->display_options['arguments']['id']['style_plugin'] = 'default_summary'; + $handler->display->display_options['arguments']['id']['default_argument_type'] = 'fixed'; + $handler->display->display_options['arguments']['id']['validate_fail'] = 'empty'; + $handler->display->display_options['arguments']['id']['glossary'] = 0; + $handler->display->display_options['arguments']['id']['limit'] = '0'; + $handler->display->display_options['arguments']['id']['transform_dash'] = 0; + /* Filter: Feeds log: Feed node id */ + $handler->display->display_options['filters']['feed_nid']['id'] = 'feed_nid'; + $handler->display->display_options['filters']['feed_nid']['table'] = 'feeds_log'; + $handler->display->display_options['filters']['feed_nid']['field'] = 'feed_nid'; + $handler->display->display_options['filters']['feed_nid']['value']['value'] = '0'; + /* Filter: Feeds log: Severity */ + $handler->display->display_options['filters']['severity']['id'] = 'severity'; + $handler->display->display_options['filters']['severity']['table'] = 'feeds_log'; + $handler->display->display_options['filters']['severity']['field'] = 'severity'; + $handler->display->display_options['filters']['severity']['exposed'] = TRUE; + $handler->display->display_options['filters']['severity']['expose']['operator'] = 'severity_op'; + $handler->display->display_options['filters']['severity']['expose']['label'] = 'Severity'; + $handler->display->display_options['filters']['severity']['expose']['use_operator'] = FALSE; + $handler->display->display_options['filters']['severity']['expose']['identifier'] = 'severity'; + $handler->display->display_options['filters']['severity']['expose']['reduce'] = 0; + + /* Display: Standalone importer page */ + $handler = $view->new_display('page', 'Standalone importer page', 'page_1'); + $handler->display->display_options['path'] = 'import/%/log'; + $handler->display->display_options['menu']['type'] = 'tab'; + $handler->display->display_options['menu']['title'] = 'Log'; + $handler->display->display_options['menu']['weight'] = '0'; + + /* Display: Feed node page */ + $handler = $view->new_display('page', 'Feed node page', 'page_2'); + $handler->display->display_options['defaults']['arguments'] = FALSE; + /* Argument: Feeds log: Feed node id */ + $handler->display->display_options['arguments']['feed_nid']['id'] = 'feed_nid'; + $handler->display->display_options['arguments']['feed_nid']['table'] = 'feeds_log'; + $handler->display->display_options['arguments']['feed_nid']['field'] = 'feed_nid'; + $handler->display->display_options['arguments']['feed_nid']['default_action'] = 'not found'; + $handler->display->display_options['arguments']['feed_nid']['style_plugin'] = 'default_summary'; + $handler->display->display_options['arguments']['feed_nid']['default_argument_type'] = 'fixed'; + $handler->display->display_options['arguments']['feed_nid']['break_phrase'] = 0; + $handler->display->display_options['arguments']['feed_nid']['not'] = 0; + $handler->display->display_options['defaults']['filters'] = FALSE; + /* Filter: Feeds log: Severity */ + $handler->display->display_options['filters']['severity']['id'] = 'severity'; + $handler->display->display_options['filters']['severity']['table'] = 'feeds_log'; + $handler->display->display_options['filters']['severity']['field'] = 'severity'; + $handler->display->display_options['filters']['severity']['exposed'] = TRUE; + $handler->display->display_options['filters']['severity']['expose']['operator'] = 'severity_op'; + $handler->display->display_options['filters']['severity']['expose']['label'] = 'Severity'; + $handler->display->display_options['filters']['severity']['expose']['use_operator'] = FALSE; + $handler->display->display_options['filters']['severity']['expose']['identifier'] = 'severity'; + $handler->display->display_options['filters']['severity']['expose']['reduce'] = 0; + $handler->display->display_options['path'] = 'node/%/log'; + $handler->display->display_options['menu']['type'] = 'tab'; + $handler->display->display_options['menu']['title'] = 'Log'; + $handler->display->display_options['menu']['weight'] = '12'; + + /* Display: All entries */ + $handler = $view->new_display('page', 'All entries', 'page_3'); + $handler->display->display_options['defaults']['title'] = FALSE; + $handler->display->display_options['title'] = 'Feeds log'; + $handler->display->display_options['defaults']['relationships'] = FALSE; + /* Relationship: Feeds log: Feed node */ + $handler->display->display_options['relationships']['feed_nid']['id'] = 'feed_nid'; + $handler->display->display_options['relationships']['feed_nid']['table'] = 'feeds_log'; + $handler->display->display_options['relationships']['feed_nid']['field'] = 'feed_nid'; + $handler->display->display_options['defaults']['fields'] = FALSE; + /* Field: Feeds log: Log time */ + $handler->display->display_options['fields']['log_time']['id'] = 'log_time'; + $handler->display->display_options['fields']['log_time']['table'] = 'feeds_log'; + $handler->display->display_options['fields']['log_time']['field'] = 'log_time'; + $handler->display->display_options['fields']['log_time']['alter']['alter_text'] = 0; + $handler->display->display_options['fields']['log_time']['alter']['make_link'] = 0; + $handler->display->display_options['fields']['log_time']['alter']['absolute'] = 0; + $handler->display->display_options['fields']['log_time']['alter']['trim'] = 0; + $handler->display->display_options['fields']['log_time']['alter']['word_boundary'] = 1; + $handler->display->display_options['fields']['log_time']['alter']['ellipsis'] = 1; + $handler->display->display_options['fields']['log_time']['alter']['strip_tags'] = 0; + $handler->display->display_options['fields']['log_time']['alter']['html'] = 0; + $handler->display->display_options['fields']['log_time']['hide_empty'] = 0; + $handler->display->display_options['fields']['log_time']['empty_zero'] = 0; + $handler->display->display_options['fields']['log_time']['date_format'] = 'custom'; + $handler->display->display_options['fields']['log_time']['custom_date_format'] = 'Y-m-d H:i:s'; + /* Field: Feeds log: Request time */ + $handler->display->display_options['fields']['request_time']['id'] = 'request_time'; + $handler->display->display_options['fields']['request_time']['table'] = 'feeds_log'; + $handler->display->display_options['fields']['request_time']['field'] = 'request_time'; + $handler->display->display_options['fields']['request_time']['alter']['alter_text'] = 0; + $handler->display->display_options['fields']['request_time']['alter']['make_link'] = 0; + $handler->display->display_options['fields']['request_time']['alter']['absolute'] = 0; + $handler->display->display_options['fields']['request_time']['alter']['trim'] = 0; + $handler->display->display_options['fields']['request_time']['alter']['word_boundary'] = 1; + $handler->display->display_options['fields']['request_time']['alter']['ellipsis'] = 1; + $handler->display->display_options['fields']['request_time']['alter']['strip_tags'] = 0; + $handler->display->display_options['fields']['request_time']['alter']['html'] = 0; + $handler->display->display_options['fields']['request_time']['hide_empty'] = 0; + $handler->display->display_options['fields']['request_time']['empty_zero'] = 0; + $handler->display->display_options['fields']['request_time']['date_format'] = 'custom'; + $handler->display->display_options['fields']['request_time']['custom_date_format'] = 'Y-m-d H:i:s'; + /* Field: Feeds log: Log message */ + $handler->display->display_options['fields']['message']['id'] = 'message'; + $handler->display->display_options['fields']['message']['table'] = 'feeds_log'; + $handler->display->display_options['fields']['message']['field'] = 'message'; + $handler->display->display_options['fields']['message']['label'] = 'Message'; + $handler->display->display_options['fields']['message']['hide_empty'] = 0; + $handler->display->display_options['fields']['message']['empty_zero'] = 0; + /* Field: Feeds log: Severity */ + $handler->display->display_options['fields']['severity']['id'] = 'severity'; + $handler->display->display_options['fields']['severity']['table'] = 'feeds_log'; + $handler->display->display_options['fields']['severity']['field'] = 'severity'; + $handler->display->display_options['fields']['severity']['hide_empty'] = 0; + $handler->display->display_options['fields']['severity']['empty_zero'] = 0; + /* Field: Feeds log: Importer name */ + $handler->display->display_options['fields']['importer_name']['id'] = 'importer_name'; + $handler->display->display_options['fields']['importer_name']['table'] = 'feeds_log'; + $handler->display->display_options['fields']['importer_name']['field'] = 'importer_name'; + $handler->display->display_options['fields']['importer_name']['label'] = 'Importer'; + $handler->display->display_options['fields']['importer_name']['hide_empty'] = 0; + $handler->display->display_options['fields']['importer_name']['empty_zero'] = 0; + $handler->display->display_options['fields']['importer_name']['link'] = '2'; + /* Field: Node: Title */ + $handler->display->display_options['fields']['title']['id'] = 'title'; + $handler->display->display_options['fields']['title']['table'] = 'node'; + $handler->display->display_options['fields']['title']['field'] = 'title'; + $handler->display->display_options['fields']['title']['relationship'] = 'feed_nid'; + $handler->display->display_options['fields']['title']['label'] = 'Feed node'; + $handler->display->display_options['fields']['title']['alter']['alter_text'] = 0; + $handler->display->display_options['fields']['title']['alter']['make_link'] = 0; + $handler->display->display_options['fields']['title']['alter']['absolute'] = 0; + $handler->display->display_options['fields']['title']['alter']['trim'] = 1; + $handler->display->display_options['fields']['title']['alter']['max_length'] = '40'; + $handler->display->display_options['fields']['title']['alter']['word_boundary'] = 1; + $handler->display->display_options['fields']['title']['alter']['ellipsis'] = 1; + $handler->display->display_options['fields']['title']['alter']['strip_tags'] = 0; + $handler->display->display_options['fields']['title']['alter']['html'] = 0; + $handler->display->display_options['fields']['title']['hide_empty'] = 0; + $handler->display->display_options['fields']['title']['empty_zero'] = 0; + $handler->display->display_options['fields']['title']['link_to_node'] = 1; + $handler->display->display_options['defaults']['arguments'] = FALSE; + $handler->display->display_options['defaults']['filters'] = FALSE; + /* Filter: Feeds log: Severity */ + $handler->display->display_options['filters']['severity']['id'] = 'severity'; + $handler->display->display_options['filters']['severity']['table'] = 'feeds_log'; + $handler->display->display_options['filters']['severity']['field'] = 'severity'; + $handler->display->display_options['filters']['severity']['exposed'] = TRUE; + $handler->display->display_options['filters']['severity']['expose']['operator'] = 'severity_op'; + $handler->display->display_options['filters']['severity']['expose']['label'] = 'Severity'; + $handler->display->display_options['filters']['severity']['expose']['use_operator'] = FALSE; + $handler->display->display_options['filters']['severity']['expose']['identifier'] = 'severity'; + $handler->display->display_options['filters']['severity']['expose']['reduce'] = 0; + $handler->display->display_options['path'] = 'admin/reports/feeds'; + $handler->display->display_options['menu']['type'] = 'normal'; + $handler->display->display_options['menu']['title'] = 'Feeds log'; + $handler->display->display_options['menu']['description'] = 'Review log messages of imports and subscriptions to feeds.'; + $handler->display->display_options['menu']['weight'] = '0'; + $handler->display->display_options['menu']['name'] = 'management'; + + $views[$view->name] = $view; + + return $views; +} diff --git a/sites/all/modules/feeds/views/feeds_views_handler_argument_importer_id.inc b/sites/all/modules/feeds/views/feeds_views_handler_argument_importer_id.inc new file mode 100644 index 0000000000000000000000000000000000000000..9415812eaa14426127f05ff76b7f540f37a8df21 --- /dev/null +++ b/sites/all/modules/feeds/views/feeds_views_handler_argument_importer_id.inc @@ -0,0 +1,24 @@ +<?php + +/** + * @file + * Argument handler for importer ids. + */ +class feeds_views_handler_argument_importer_id extends views_handler_argument_string { + + /** + * Argument must be a valid importer id. + */ + function validate_arg($arg) { + // By using % in URLs, arguments could be validated twice; this eases + // that pain. + if (isset($this->argument_validated)) { + return $this->argument_validated; + } + $this->argument_validated = FALSE; + if (in_array($arg, feeds_enabled_importers())) { + $this->argument_validated = TRUE; + } + return $this->argument_validated; + } +} diff --git a/sites/all/modules/feeds/views/feeds_views_handler_field_importer_name.inc b/sites/all/modules/feeds/views/feeds_views_handler_field_importer_name.inc new file mode 100644 index 0000000000000000000000000000000000000000..9b16cdf5223c0d290b4e8e04d17eafea60676227 --- /dev/null +++ b/sites/all/modules/feeds/views/feeds_views_handler_field_importer_name.inc @@ -0,0 +1,63 @@ +<?php + +/** + * @file + * Render an importer name. + */ + +class feeds_views_handler_field_importer_name extends views_handler_field { + + /** + * Overrides parent::option_definition(). + */ + function option_definition() { + $options = parent::option_definition(); + $options['link'] = array('default' => 0); + return $options; + } + + /** + * Overrides parent::options_form(). + */ + function options_form(&$form, &$form_state) { + parent::options_form($form, $form_state); + $options = array( + 0 => t('Do not link'), + 1 => t('Link to configuration form'), + 2 => t('Link to standalone form'), + ); + $form['link'] = array( + '#type' => 'radios', + '#title' => t('Link importer name'), + '#description' => t('If "Link to standalone form" is used, only importers that use a standalone form will be linked.'), + '#default_value' => isset($this->options['link']) ? $this->options['link'] : FALSE, + '#options' => $options, + ); + } + + /** + * Overrides parent::render(). + */ + function render($values) { + try { + $importer = feeds_importer($values->{$this->field_alias})->existing(); + if ($this->options['link'] == 1) { + return l($importer->config['name'], 'admin/structure/feeds/' . $importer->id); + } + elseif ($this->options['link'] == 2 && empty($importer->config['content_type'])) { + return l($importer->config['name'], 'import/' . $importer->id); + } + return check_plain($importer->config['name']); + } + catch (Exception $e) { + return t('Missing importer'); + } + } + + /** + * Disallows advanced rendering. + */ + function allow_advanced_render() { + return FALSE; + } +} diff --git a/sites/all/modules/feeds/views/feeds_views_handler_field_log_message.inc b/sites/all/modules/feeds/views/feeds_views_handler_field_log_message.inc new file mode 100644 index 0000000000000000000000000000000000000000..6fcdeeafb534fda50ee8188c04e1512fe666400f --- /dev/null +++ b/sites/all/modules/feeds/views/feeds_views_handler_field_log_message.inc @@ -0,0 +1,25 @@ +<?php + +/** + * @file + * Views handler for displaying a log message. + */ + +class feeds_views_handler_field_log_message extends views_handler_field { + + /** + * Override parent::render(). + */ + function render($values) { + $message = $values->{$this->field_alias}; + $variables = unserialize($values->{$this->aliases['variables']}); + return t($message, $variables); + } + + /** + * Disallow advanced rendering. + */ + function allow_advanced_render() { + return FALSE; + } +} diff --git a/sites/all/modules/feeds/views/feeds_views_handler_field_severity.inc b/sites/all/modules/feeds/views/feeds_views_handler_field_severity.inc new file mode 100644 index 0000000000000000000000000000000000000000..c4bdf07748784bc5715b2151df8d95c6ba5b85e1 --- /dev/null +++ b/sites/all/modules/feeds/views/feeds_views_handler_field_severity.inc @@ -0,0 +1,25 @@ +<?php + +/** + * @file + * Views handler for displaying the logs severity field. + */ + +class feeds_views_handler_field_severity extends views_handler_field { + + /** + * Override parent::render(). + */ + function render($values) { + $value = $values->{$this->field_alias}; + $levels = watchdog_severity_levels(); + return $levels[$value]; + } + + /** + * Disallow advanced rendering. + */ + function allow_advanced_render() { + return FALSE; + } +} diff --git a/sites/all/modules/feeds/views/feeds_views_handler_field_source.inc b/sites/all/modules/feeds/views/feeds_views_handler_field_source.inc new file mode 100644 index 0000000000000000000000000000000000000000..8e137ac79ec71f418c2da40dc395847a50286ab4 --- /dev/null +++ b/sites/all/modules/feeds/views/feeds_views_handler_field_source.inc @@ -0,0 +1,31 @@ +<?php + +/** + * @file + * Views handler for showing a feeds source config field. + */ + +class feeds_views_handler_field_source extends views_handler_field { + + /** + * Override parent::render(). + */ + function render($values) { + $value = unserialize($values->{$this->field_alias}); + if (isset($value['FeedsHTTPFetcher']['source'])) { + return check_url($value['FeedsHTTPFetcher']['source']); + } + elseif (isset($value['FeedsFileFetcher']['feeds_source'])) { + // @todo This is untested. + return $GLOBALS['base_url'] . '/' . check_plain($value['FeedsFileFetcher']['feeds_source']); + } + return ''; + } + + /** + * Disallow advanced rendering. + */ + function allow_advanced_render() { + return FALSE; + } +} diff --git a/sites/all/modules/feeds/views/feeds_views_handler_filter_severity.inc b/sites/all/modules/feeds/views/feeds_views_handler_filter_severity.inc new file mode 100644 index 0000000000000000000000000000000000000000..4d9b9495c00e37dfc9d856064c88be5ff7666893 --- /dev/null +++ b/sites/all/modules/feeds/views/feeds_views_handler_filter_severity.inc @@ -0,0 +1,13 @@ +<?php + +/** + * @file + * Filter by severity. + */ + +class feeds_views_handler_filter_severity extends views_handler_filter_in_operator { + function get_value_options() { + $this->value_title = t('Severity'); + $this->value_options = watchdog_severity_levels(); + } +} diff --git a/sites/all/modules/feeds_imagegrabber/LICENSE.txt b/sites/all/modules/feeds_imagegrabber/LICENSE.txt new file mode 100644 index 0000000000000000000000000000000000000000..d159169d1050894d3ea3b98e1c965c4058208fe1 --- /dev/null +++ b/sites/all/modules/feeds_imagegrabber/LICENSE.txt @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + <signature of Ty Coon>, 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/sites/all/modules/feeds_imagegrabber/README.TXT b/sites/all/modules/feeds_imagegrabber/README.TXT new file mode 100644 index 0000000000000000000000000000000000000000..6642a8b651e88a1ab7b715ad2bc1d77e26ec88d2 --- /dev/null +++ b/sites/all/modules/feeds_imagegrabber/README.TXT @@ -0,0 +1,42 @@ +// $Id$ + +Feeds Image Grabber (FIG), an addon module for Feeds module, grabs (downloads) images for each feed-item from their respective webpages and maps to an image field in the node created by Feeds (Node Processor). + +Visit: +Project page: http://drupal.org/project/feeds_imagegrabber +Release page: http://publicmind.in/blog/drupal-feeds-image-grabber +Tutorial: http://publicmind.in/blog/tutorial-for-feeds-image-grabber + + +Dependencies: +------------- +Feeds +Image + +Installation +------------ + +1) Place this module directory in your modules folder (this will usually be "sites/all/modules/"). +3) Enable the module. + +Usage +------ + +Whenever you create a new feeds content type, you will see extra options for Feeds Image Grabber: + +1) Enable Feeds Image Grabber: Check this box, if you want to enable FIG for the feed in concern. +2) and 3) Id/Class: select the identifying tag and enter its id/class (see tutorial[http://publicmind.in/blog/tutorial-for-feedapi-imagegrabber] for details.) +4) Feeling Lucky +5) Execution time: Percentage of Maximum PHP execution time to take while grabbing images for each feed-item. + +After these settings, go to mappings setting for FeedsNodeProcessor and map Item URL(Link) to the image field of your choice. Image fields will be appended with "(FIG)" which are processed by Feeds Image Grabber. + +Maximum size, Minimum resolution, Maximum resolution settings for FIG can be set for each image field on their "Manage Fields" page. FIG considers the settings of individual fields while grabbing images for each feed-item. + +For detailed explanation: http://publicmind.in/blog/tutorial-for-feeds-image-grabber/ + +Author +------ + +publicmind wrote and maintains this module. The author can be contacted for paid customizations of this module as well as +Drupal consulting and development at http://drupal.org/user/472412 \ No newline at end of file diff --git a/sites/all/modules/feeds_imagegrabber/feeds_imagegrabber.info b/sites/all/modules/feeds_imagegrabber/feeds_imagegrabber.info new file mode 100644 index 0000000000000000000000000000000000000000..ff21bdb40044a3a104cd4e187d68bd3b7306fae4 --- /dev/null +++ b/sites/all/modules/feeds_imagegrabber/feeds_imagegrabber.info @@ -0,0 +1,14 @@ +; $Id$ +name = "Feeds Image Grabber" +description = "Grabs image for each feed-item from their respective web pages and stores it in an image field." +core = 7.x +package = Feeds Addon +dependencies[] = feeds +dependencies[] = image +files[] = libraries/url_to_absolute.inc +; Information added by drupal.org packaging script on 2012-11-01 +version = "7.x-1.0-alpha2" +core = "7.x" +project = "feeds_imagegrabber" +datestamp = "1351773135" + diff --git a/sites/all/modules/feeds_imagegrabber/feeds_imagegrabber.install b/sites/all/modules/feeds_imagegrabber/feeds_imagegrabber.install new file mode 100644 index 0000000000000000000000000000000000000000..ed3bf4817084ea3f0ffe8ba02d87d9e58c9b596c --- /dev/null +++ b/sites/all/modules/feeds_imagegrabber/feeds_imagegrabber.install @@ -0,0 +1,63 @@ +<?php +// $Id$ + +/** + * @file + * Schema definitions install/update/uninstall hooks. + */ + +/** + * Implements hook_schema(). + */ +function feeds_imagegrabber_schema() { + $schema['feeds_imagegrabber'] = array( + 'description' => 'stores the settings of feeds image grabber per feed node', + 'fields' => array( + 'feed_nid' => array( + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'default' => 0, + 'description' => 'The feed nid for which the feeds image grabber is enabled', + ), + 'enabled' => array( + 'description' => 'Current enabled status for this feed nid', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'unsigned' => TRUE, + ), + 'id_class' => array( + 'description' => 'Identifying tag attribute an ID (1) or Class(2) or nothing (0)?', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'unsigned' => TRUE, + ), + 'id_class_desc' => array( + 'description' => 'Identifying tag attribute value.', + 'type' => 'varchar', + 'length' => '128', + ), + 'feeling_lucky' => array( + 'description' => 'Get the first image from between the tags or not', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'unsigned' => TRUE, + ), + 'exec_time' => array( + 'description' => 'Percentage of PHP maximum execution time to utilize for grabbing image for each feed item.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 10, + 'unsigned' => TRUE, + ), + ), + 'primary key' => array( + 'feed_nid', + ), + ); + + return $schema; +} \ No newline at end of file diff --git a/sites/all/modules/feeds_imagegrabber/feeds_imagegrabber.module b/sites/all/modules/feeds_imagegrabber/feeds_imagegrabber.module new file mode 100644 index 0000000000000000000000000000000000000000..c06d1b346489ab99bed58a33887cbb721b91e06e --- /dev/null +++ b/sites/all/modules/feeds_imagegrabber/feeds_imagegrabber.module @@ -0,0 +1,732 @@ +<?php +// $Id$ + + +/** + * @file + * Grabs image for each feed-item from their respective web pages and stores + * it in an image field. Requires Feeds module. + * + */ + +//============= +//DRUPAL HOOKS. +//============= + +/** + * Implements hook_menu(). +(). + */ +function feeds_imagegrabber_menu() { + $items = array(); + $items['admin/config/content/feeds_imagegrabber'] = array( + 'title' => 'Feeds Image Grabber', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('feeds_imagegrabber_admin'), + 'access arguments' => array('administer site configuration'), + 'description' => 'Configure default options for Feeds Image Grabber', + ); + + return $items; +} + +/** + * Implements hook_form_BASE_FORM_ID_alter(). + */ +function feeds_imagegrabber_form_node_form_alter(&$form, &$form_state, $form_id) { + if ($importer_id = feeds_get_importer_id($form['#node']->type)) { + // Use the values from $form_state if available + if (isset($form_state['values']['feeds_imagegrabber'])) { + $settings = $form_state['values']['feeds_imagegrabber']; + } + elseif (!isset($form['#node']->nid) || ($settings = feeds_imagegrabber_get_settings($form['#node']->nid)) === FALSE) { + $settings = feeds_imagegrabber_get_default_settings(); + } + + $form['feeds_imagegrabber'] = array( + '#type' => 'fieldset', + '#title' => t('Feeds Image Grabber'), + '#tree' => TRUE, + '#collapsible' => TRUE, + '#collapsed' => TRUE, + ); + + feeds_imagegrabber_form($form, $settings); + + $form['#validate'][] = 'feeds_imagegrabber_form_validate'; + } +} + +/** + * Validates the feeds_imagegrabber form of the node. + */ +function feeds_imagegrabber_form_validate($form, &$form_state) { + if ($form_state['values']['feeds_imagegrabber']['enabled'] == 1) { + $id_class = $form_state['values']['feeds_imagegrabber']['id_class']; + $id_class_desc = $form_state['values']['feeds_imagegrabber']['id_class_desc']; + if ($id_class) { + if (!isset($id_class_desc) || empty($id_class_desc) || $id_class_desc == '') { + form_set_error('feeds_imagegrabber][id_class_desc', "Specify the id/class of the desired tag."); + } + if ($id_class == 1 && !preg_match('/^[a-zA-Z]+[_a-zA-Z0-9-]*$/', $id_class_desc)) { + form_set_error('feeds_imagegrabber][id_class_desc', "Only alphabets, digits, hyphens and underscores are allowed in HTML id"); + } + elseif ($id_class == 2 && !preg_match('/^[a-zA-Z]+[_a-zA-Z0-9- ]*$/', $id_class_desc)) { + form_set_error('feeds_imagegrabber][id_class_desc', "Only alphabets, digits, hyphens, spaces and underscores are allowed in HTML class"); + } + } + else { + form_set_value($form['feeds_imagegrabber']['id_class_desc'], '', $form_state); + } + $temp = $form_state['values']['feeds_imagegrabber']['exec_time']; + if (!is_numeric($temp) || $temp < 10 || $temp != round($temp) || $temp > 75) { + form_set_error('feeds_imagegrabber][exec_time', t('Select the correct option for FIG execution time.')); + } + } +} + +/** + * Implements hook_node_insert(). + */ +function feeds_imagegrabber_node_insert($node) { + feeds_imagegrabber_node_update($node); +} + +/** + * Implements hook_node_update(). + */ +function feeds_imagegrabber_node_update($node) { + if ($importer_id = feeds_get_importer_id($node->type)) { + if (isset($node->feeds_imagegrabber['enabled']) && $node->feeds_imagegrabber['enabled']) { + $settings = array( + 'enabled' => $node->feeds_imagegrabber['enabled'], + 'id_class' => $node->feeds_imagegrabber['id_class'], + 'id_class_desc' => $node->feeds_imagegrabber['id_class_desc'], + 'exec_time' => $node->feeds_imagegrabber['exec_time'], + 'feeling_lucky' => $node->feeds_imagegrabber['feeling_lucky'], + ); + + db_merge('feeds_imagegrabber') + ->key(array('feed_nid' => $node->nid)) + ->fields($settings) + ->execute(); + } + else { + db_update('feeds_imagegrabber') + ->fields(array('enabled' => 0)) + ->condition('feed_nid', $node->nid) + ->execute(); + } + } +} + +/** + * Implements hook_node_delete(). + */ +function feeds_imagegrabber_node_delete($node) { + @db_query("DELETE FROM {feeds_imagegrabber} where feed_nid = %d", $node->nid); +} + +//================== +// FEEDS HOOKS +//================== + +/** + * Implements hook_feeds_processor_targets_alter(). + * + * @see FeedsNodeProcessor::getMappingTargets(). + */ +function feeds_imagegrabber_feeds_processor_targets_alter(&$targets, $entity_type, $bundle_name) { + foreach (field_info_instances($entity_type, $bundle_name) as $name => $instance) { + $info = field_info_field($name); + if (in_array($info['type'], array('image'))) { + $targets[$name . ':fig'] = array( + 'name' => check_plain($instance['label']) . ' (FIG)', + 'callback' => 'feeds_imagegrabber_feeds_set_target', + 'description' => t('The @label field of the node.', array('@label' => $instance['label'])), + ); + } + } +} + +/** + * Callback for mapping. Here is where the actual mapping happens. + */ +function feeds_imagegrabber_feeds_set_target($source, $entity, $target, $page_url) { + if (empty($page_url)) { + return; + } + + $feed_nid = $entity->feeds_item->feed_nid; + $settings = feeds_imagegrabber_get_settings($feed_nid); + + // Fall back to the defaults. + if (!$settings) { + $settings = feeds_imagegrabber_get_default_settings(); + } + if (!$settings || !$settings['enabled']) { + return FALSE; + } + + if (!feeds_imagegrabber_include_library('url_to_absolute.inc', 'feeds_imagegrabber')) { + watchdog('feeds_imagegrabber', 'url conversion script is missing. ', array(), WATCHDOG_ERROR, 'admin/reports/dblog/feeds_imagegrabber'); + return FALSE; + } + + list($field_name) = explode(':', $target); + + list($entity_id, $vid, $bundle_name) = entity_extract_ids($entity->feeds_item->entity_type, $entity); + $instance_info = field_info_instance($entity->feeds_item->entity_type, $field_name, $bundle_name); + $info = field_info_field($field_name); + + $max_filesize = parse_size(file_upload_max_size()); + if (!empty($instance_info['settings']['max_filesize']) && parse_size($instance_info['settings']['max_filesize']) < $max_filesize) { + $max_filesize = parse_size($instance_info['settings']['max_filesize']); + } + + $max_exec_time = ini_get('max_execution_time'); + $timeout = $max_exec_time == 0 ? 10 : ($settings['exec_time'] * $max_exec_time / 100); + $page_time = timer_read('page')/1000; + if(function_exists('encode_url')) { + $page_url = encode_url($page_url); + } + + if (valid_url($page_url)) { + $xml = feeds_imagegrabber_webpage_scraper($page_url, $settings['id_class'], $settings['id_class_desc'], $timeout); + if ($xml == FALSE) { + return; + } + $timeout = $timeout - timer_read('page')/1000 + $page_time; + + $options = array( + 'max_imagesize' => $max_filesize, + 'timeout' => $timeout, + 'feeling_lucky' => $settings['feeling_lucky'], + 'cardinality' => $info['cardinality'], + ); + + $images = feeds_imagegrabber_scrape_images($xml, $page_url, $settings, $options); + if ($images == FALSE || count($images) <= 0) { + return; + } + + asort($images); + $images = array_reverse($images, TRUE); + + $data = array(); + if (!empty($entity->uid)) { + $data[$entity->feeds_item->entity_type] = $entity; + } + + $field = isset($entity->$field_name) ? $entity->$field_name : array(); + $target_dir = file_field_widget_uri($info, $instance_info, $data); + + $image_count = 0; + foreach ($images as $url => $size) { + // We don't need to pass an encoded url to feeds. + // Feeds will handle encoding spaces for retrieval and replacing spaces + // with underscores when saving to the file system. + $url = rawurldecode($url); + $enclosure = new FeedsEnclosure($url, 'application/octet-stream'); + if (($file = $enclosure->getFile($target_dir)) && ($file = feeds_imagegrabber_is_image($file)) && !count(feeds_imagegrabber_widget_file_validator($file, $instance_info))) { + $field['und'][$image_count] = (array)$file; + $field['und'][$image_count]['display'] = 1; + + $image_count++; + if($image_count == $info['cardinality']) { + break; + } + } + } + if ($image_count) { + $entity->$field_name = $field; + } + } +} + +//================== +//HELPER FUNCTIONS +//================== + +/** + * Retrieve settings for a feed node from the database. + * + * @param $feed_nid + * The nid of the feed node. + * + * @return + * An array of settings or FALSE if settings not found. + * + */ +function feeds_imagegrabber_get_settings($feed_nid) { + $settings = db_query("SELECT enabled, id_class, id_class_desc, feeling_lucky, exec_time FROM {feeds_imagegrabber} WHERE feed_nid = :feed_nid", array(':feed_nid' => $feed_nid))->fetchAssoc(); + return $settings; +} + +/** + * Retrieve the default settings for a feed node from the database. + * + * @return + * An array of settings. + * + */ +function feeds_imagegrabber_get_default_settings() { + $default = array( + 'enabled' => 0, + 'id_class' => 0, + 'id_class_desc' => '', + 'exec_time' => 10, + 'feeling_lucky' => 0, + ); + + return variable_get('feeds_imagegrabber', $default); +} + +/** + * Implementation of the default settings admin form. + */ +function feeds_imagegrabber_admin($form, &$form_state) { + $form = array(); + $settings = feeds_imagegrabber_get_default_settings(); + + $form['feeds_imagegrabber'] = array( + '#type' => 'fieldset', + '#title' => t('Default Settings'), + '#tree' => TRUE, + '#collapsible' => FALSE, + ); + + feeds_imagegrabber_form($form, $settings); + $form['#validate'][] = 'feeds_imagegrabber_form_validate'; + + return system_settings_form($form); +} + +/** + * Appends the form with the Feeds Image Grabber form using the + * passed default settings. + * + * @param &$form + * The form to append under the 'feeds_imagegrabber' fieldset. + * @param $default_settings + * The default values of the form elements. + */ +function feeds_imagegrabber_form(&$form, $default_settings) { + $form['feeds_imagegrabber']['enabled'] = array( + '#type' => 'checkbox', + '#title' => t('Enable Feeds Image Grabber'), + '#description' => t('Check if you want to download images from URL of the feed items.'), + '#default_value' => $default_settings['enabled'], + ); + + $form['feeds_imagegrabber']['id_class'] = array( + '#type' => 'radios', + '#title' => t('Search for the images between the tag which is identified by'), + '#options' => array( + t('None, search the whole web-page for the images.'), + t('an Id'), + t('a Class'), + ), + '#default_value' => $default_settings['id_class'], + ); + + $form['feeds_imagegrabber']['id_class_desc'] = array( + '#type' => 'textfield', + '#title' => t('<i>Id</i> or <i>Class</i> of the HTML tag (Leave empty if you selected <i>None</i> above.)'), + '#default_value' => $default_settings['id_class_desc'], + '#description' => t('Separate multiple classes with spaces (as present in the HTML)'), + '#maxlength' => 100, + ); + + $form['feeds_imagegrabber']['feeling_lucky'] = array( + '#type' => 'radios', + '#title' => t('Feeling lucky, huh?'), + '#options' => array( + t('No, select the images based of their size (large to small) between the tag.'), + t('Yes, select the images based on their position (top to bottom) between the tag. (Recommended)'), + ), + '#default_value' => $default_settings['feeling_lucky'], + ); + $form['feeds_imagegrabber']['exec_time'] = array( + '#type' => 'select', + '#title' => t('Execution time[%]'), + '#options' => drupal_map_assoc(array(10, 20, 30, 50, 75)), + '#default_value' => $default_settings['exec_time'], + '#description' => t('Select the percentage of maximum PHP execution time to take while grabbing images for a feed item.'), + ); +} + +/** + * Validates the size of an file accessible through a http url. + * + * @param $file_url + * A string specifying the formatted file url. + * @param $max_size + * Maximum size of the file to be downloaded. + * @param $timeout + * A float representing the maximum number of seconds the function call + * may take. The default is 10 seconds. If a timeout occurs, the retuen + * code is set to the HTTP_REQUEST_TIMEOUT constant. + * @param $max_redirects + * An integer representing how many times a redirect may be followed. + * Defaults to 3. + * + * @return + * An integer code containing filesize in case the file exists and conforms to the + * size limit, -1 otherwise. + * + */ +function feeds_imagegrabber_validate_download_size($file_url, $max_size, $timeout = 10, $max_redirects = 3) { + $options = array( + 'headers' => array(), + 'method' => 'HEAD', + 'data' => NULL, + 'max_redirects' => $max_redirects, + 'timeout' => $timeout, + ); + $result = drupal_http_request($file_url, $options); + + if ($result->code == 200 && isset($result->headers) && is_array($result->headers)) { + //Bug #882992, some servers may return keys with different case. + $headers = array_change_key_case($result->headers); + + if (isset($headers['content-length']) && $headers['content-length'] <= $max_size) { + return $headers['content-length']; + } + } + return -1; +} + +/** + * Scrape the webpage using the id or the css class of a tag and returns the + * HTML between the tag. + * + * @param $page_url + * A string specifying the page url to scrape. If there is a redirect, it is + * changed to the redirect_url. + * @param $itype + * A positive integer value representing the identifier type for the tag: + * - 0 : selects content between <body> </body>. + * - 1 : selects content between the tag identified by an ID. + * - 2 : selects content between the first tag identified by a CSS class. + * @param $ivalue + * A string specifying the ID or the CSS class. + * @param $timeout + * A float representing the maximum number of seconds the function call + * may take. The default is 15 seconds. If a timeout occurs, the retuen + * code is set to the HTTP_REQUEST_TIMEOUT constant. + * @param $max_redirects + * An integer representing how many times a redirect may be followed. + * Defaults to 3. + * @param $error_log + * An array which contains the error codes and messages in case the functions fails. + * + * @return + * FALSE on failure, OR content between the tags as XML on success + * + */ +function feeds_imagegrabber_webpage_scraper(&$page_url, $itype, $ivalue = '', $timeout = 15, $max_redirects = 3, &$error_log = array()) { + $options = array( + 'headers' => array(), + 'method' => 'GET', + 'data' => NULL, + 'max_redirects' => $max_redirects, + 'timeout' => $timeout, + ); + + $result = drupal_http_request($page_url, $options); + if (isset($result->redirect_code) && in_array($result->redirect_code, array(301, 302, 307))) { + $page_url = $result->redirect_url; + } + + if ($result->code != 200) { + $error_log['code'] = $result->code; + $error_log['error'] = "unable to retrieve content from web page"; + return FALSE; + } + if (empty($result->data) || drupal_strlen($result->data) <= 0) { + $error_log['code'] = -1; + $error_log['error'] = "no data available on url"; + return FALSE; + } + + $doc = new DOMDocument(); + if (@$doc->loadHTML($result->data) === FALSE) { + $error_log['code'] = -2; + $error_log['error'] = "unable to parse the html content"; + return FALSE; + } + + if ($itype == 0) { + $items = @$doc->getElementsByTagName("body"); + if ($items != NULL && $items->length > 0) { + $dist = $items->item(0); + } + else { + $dist = NULL; + } + } + elseif ($itype == 1) { + $dist = @$doc->getElementById($ivalue); + } + elseif ($itype == 2) { + $xpath = new DOMXPath($doc); + //Normalize whitespaces. + $ivalue = preg_replace('/\s\s+/', ' ', trim($ivalue)); + $items = $xpath->query("//*[@class and contains(concat(' ',normalize-space(@class),' '), ' $ivalue ')]"); + if ($items != NULL && $items->length > 0) { + $dist = $items->item(0); + } + else { + $dist = NULL; + } + } + else { + //not supported yet + $dist = NULL; + } + + if ($dist == NULL) { + $error_log['code'] = -3; + $error_log['error'] = "tag not found"; + return FALSE; + } + $content = ''; + if (($content = @$dist->ownerDocument->saveXML($dist)) === FALSE) { + $error_log['code'] = -4; + $error_log['error'] = "error converting content to XML"; + return FALSE; + } + + return $content; +} + +/** + * Scrape images from HTML/XML content. + */ +function feeds_imagegrabber_scrape_images($content, $base_url, $settings, array $options = array(), &$error_log = array()) { + + // Merge the default options. + $options += array( + 'expression' => "//img", + 'getsize' => TRUE, + 'max_imagesize' => 512000, + 'timeout' => 10, + 'max_redirects' => 3, + 'cardinality' => 1, + ); + + $doc = new DOMDocument(); + if (@$doc->loadXML($content) === FALSE && @$doc->loadHTML($content) === FALSE) { + $error_log['code'] = -5; + $error_log['error'] = "unable to parse the xml//html content"; + return FALSE; + } + + $xpath = new DOMXPath($doc); + $hrefs = @$xpath->evaluate($options['expression']); + + if ($options['getsize']) { + timer_start(__FUNCTION__); + } + + $images = array(); + $imagesize = 0; + for ($i = 0; $i < $hrefs->length; $i++) { + $url = $hrefs->item($i)->getAttribute('src'); + if (!isset($url) || empty($url) || $url == '') { + continue; + } + if (function_exists('encode_url')) { + $url = encode_url($url); + } + + if(function_exists('url_to_absolute')){ + $url = url_to_absolute($base_url, $url); + } + + if ($url == FALSE) { + continue; + } + + if ($options['getsize']) { + if (($imagesize = feeds_imagegrabber_validate_download_size($url, $options['max_imagesize'], ($options['timeout'] - timer_read(__FUNCTION__) / 1000))) != -1) { + $images[$url] = $imagesize; + if ($settings['feeling_lucky'] && count($images) == $settings['cardinality']) { + break; + } + } + if (($options['timeout'] - timer_read(__FUNCTION__) / 1000) <= 0) { + $error_log['code'] = HTTP_REQUEST_TIMEOUT; + $error_log['error'] = "timeout occured while scraping the content"; + break; + } + } + else { + $images[$url] = $imagesize; + if ($settings['feeling_lucky'] && count($images) == $settings['cardinality']) { + break; + } + } + } + + return $images; +} + +/** + * Checks that a file is an image. + * + * This check allows the image formats identified by drupal i.e. jpeg, png + * and gif. If the filename is missing an extension but is a valid image, + * its actual extension is added to it and the original file is renamed. + * + * @param $file + * The file object. + * + * @return + * The final file object, it may be modified or FALSE on failure. + */ +function feeds_imagegrabber_is_image(&$file) { + $extensions = 'jpeg jpg png gif'; + + if ($file && $filepath = $file->uri) { + $info = feeds_imagegrabber_get_image_info($filepath); + if ($info && !empty($info['extension'])) { + if (!count(file_validate_extensions($file, $extensions))) { + return $file; + } + else { + $basename = basename($filepath); + $directory = drupal_dirname($filepath); + + if ($pos = strrpos($basename, '.')) { + $name = substr($basename, 0, $pos); + $ext = substr($basename, $pos); + + $regex = '/\.(' . preg_replace('/ +/', '|', preg_quote($extensions)) . ')/i'; + if (preg_match($regex, $ext, $matches)) { + $ext = $matches[1]; + } + else { + $ext = $info['extension']; + } + $basename = $name . '.' . $ext; + } + else { + $basename .= '.' . $info['extension']; + } + + if ($basename == basename($filepath)) { + return $file; + } + + $dest = file_create_filename($basename, $directory); + if ($file = file_move($file, $dest)) { + return $file; + } + } + } + } + return FALSE; +} + +/** + * Drupal only supports GIF, JPG and PNG file formats. + * (Mostly from drupal's image_get_info() expect the file + * need not be an uploaded one) + * + * @return + * FALSE, if the file could not be found or is not an image. Otherwise, a + * keyed array containing information about the image: + * 'width' - Width in pixels. + * 'height' - Height in pixels. + * 'extension' - Commonly used file extension for the image. + * 'mime_type' - MIME type ('image/jpeg', 'image/gif', 'image/png'). + */ +function feeds_imagegrabber_get_image_info($file) { + if (!is_file($file)) { + return FALSE; + } + + $details = FALSE; + $data = @getimagesize($file); + if (isset($data) && is_array($data)) { + $extensions = array( + '1' => 'gif', + '2' => 'jpg', + '3' => 'png', + ); + $extension = array_key_exists($data[2], $extensions) ? $extensions[$data[2]] : ''; + $details = array( + 'width' => $data[0], + 'height' => $data[1], + 'extension' => $extension, + 'mime_type' => $data['mime'], + ); + } + + return $details; +} + +/** + * Includes a required library file. + * + * @param $file + * The filename to load from. + * @param $library + * The name of the library. If libraries module is installed, + * feeds_imagegrabber_include_library() will look for libraries + * with this name managed by libraries module. + */ +function feeds_imagegrabber_include_library($file, $library) { + if (module_exists('libraries') && file_exists(libraries_get_path($library) . "/$file")) { + require_once DRUPAL_ROOT . '/' . libraries_get_path($library) . "/$file"; + return TRUE; + } + else { + $paths = array( + drupal_get_path('module', 'feeds_imagegrabber'), + drupal_get_path('module', 'feeds_imagegrabber') . "/libraries", + 'sites/all/libraries', + 'sites/all/libraries/feeds_imagegrabber', + 'sites/all/libraries/absoluteurl', + 'sites/all/libraries/AbsoluteUrl', + ); + foreach ($paths as $library_path) { + $path = $library_path . "/$file"; + if (file_exists($path)) { + require_once DRUPAL_ROOT . '/' . $path; + return TRUE; + } + } + } + return FALSE; +} + +/** + * Validate the file object with field instance specific validators + * + * @param $file + * The file object to be validated. + * @param $instance + * The instance info of the image field. + * + * @return + * An array containing the errors (if any) which occurs + * while running the validators. + */ +function feeds_imagegrabber_widget_file_validator($file, $instance) { + $settings = $instance['settings']; + // Create the list of validators. + $image_validators = array(); + $image_validators['file_validate_name_length'] = array(); + if ($settings['max_resolution'] || $settings['min_resolution']) { + $image_validators['file_validate_image_resolution'] = array($settings['max_resolution'], $settings['min_resolution']); + } + $file_validators = file_field_widget_upload_validators(NULL, $instance); + $validators = array_merge($file_validators, $image_validators); + + $errors = file_validate($file, $validators); + + return $errors; +} diff --git a/sites/all/modules/feeds_imagegrabber/libraries/url_to_absolute.inc b/sites/all/modules/feeds_imagegrabber/libraries/url_to_absolute.inc new file mode 100644 index 0000000000000000000000000000000000000000..db7301bda53bbbf3ed86664c34fe38841143c993 --- /dev/null +++ b/sites/all/modules/feeds_imagegrabber/libraries/url_to_absolute.inc @@ -0,0 +1,452 @@ +<?php + +/** + * @file + * Converts a relative URL to absolute URL. + */ + +/** + * Mostly from the implementation by + * David R. Nadeau, NadeauSoftware.com. + */ + +/** + * Combine a base URL and a relative URL to produce a new + * absolute URL. The base URL is often the URL of a page, + * and the relative URL is a URL embedded on that page. + * + * This function implements the "absolutize" algorithm from + * the RFC3986 specification for URLs. + * + * This function supports multi-byte characters with the UTF-8 encoding, + * per the URL specification. + * + * Parameters: + * baseUrl the absolute base URL. + * + * url the relative URL to convert. + * + * Return values: + * An absolute URL that combines parts of the base and relative + * URLs, or FALSE if the base URL is not absolute or if either + * URL cannot be parsed. + */ +function url_to_absolute( $baseUrl, $relativeUrl ) +{ + // If relative URL has a scheme, clean path and return. + $r = split_url( $relativeUrl ); + if ( $r === FALSE ) + return FALSE; + if ( !empty( $r['scheme'] ) ) + { + if ( !empty( $r['path'] ) && $r['path'][0] == '/' ) + $r['path'] = url_remove_dot_segments( $r['path'] ); + return join_url( $r ); + } + + // Make sure the base URL is absolute. + $b = split_url( $baseUrl ); + if ( $b === FALSE || empty( $b['scheme'] ) || empty( $b['host'] ) ) + return FALSE; + $r['scheme'] = $b['scheme']; + + // If relative URL has an authority, clean path and return. + if ( isset( $r['host'] ) ) + { + if ( !empty( $r['path'] ) ) + $r['path'] = url_remove_dot_segments( $r['path'] ); + return join_url( $r ); + } + unset( $r['port'] ); + unset( $r['user'] ); + unset( $r['pass'] ); + + // Copy base authority. + $r['host'] = $b['host']; + if ( isset( $b['port'] ) ) $r['port'] = $b['port']; + if ( isset( $b['user'] ) ) $r['user'] = $b['user']; + if ( isset( $b['pass'] ) ) $r['pass'] = $b['pass']; + + // If relative URL has no path, use base path + if ( empty( $r['path'] ) ) + { + if ( !empty( $b['path'] ) ) + $r['path'] = $b['path']; + if ( !isset( $r['query'] ) && isset( $b['query'] ) ) + $r['query'] = $b['query']; + return join_url( $r ); + } + + // If relative URL path doesn't start with /, merge with base path + if ( $r['path'][0] != '/' ) + { + $base = mb_strrchr( $b['path'], '/', TRUE, 'UTF-8' ); + if ( $base === FALSE ) $base = ''; + $r['path'] = $base . '/' . $r['path']; + } + $r['path'] = url_remove_dot_segments( $r['path'] ); + return join_url( $r ); +} + +/** + * Filter out "." and ".." segments from a URL's path and return + * the result. + * + * This function implements the "remove_dot_segments" algorithm from + * the RFC3986 specification for URLs. + * + * This function supports multi-byte characters with the UTF-8 encoding, + * per the URL specification. + * + * Parameters: + * path the path to filter + * + * Return values: + * The filtered path with "." and ".." removed. + */ +function url_remove_dot_segments( $path ) +{ + // multi-byte character explode + $inSegs = preg_split( '!/!u', $path ); + $outSegs = array( ); + foreach ( $inSegs as $seg ) + { + if ( $seg == '' || $seg == '.') + continue; + if ( $seg == '..' ) + array_pop( $outSegs ); + else + array_push( $outSegs, $seg ); + } + $outPath = implode( '/', $outSegs ); + if ( $path[0] == '/' ) + $outPath = '/' . $outPath; + // compare last multi-byte character against '/' + if ( $outPath != '/' && + (mb_strlen($path)-1) == mb_strrpos( $path, '/', 'UTF-8' ) ) + $outPath .= '/'; + return $outPath; +} + + +/** + * This function parses an absolute or relative URL and splits it + * into individual components. + * + * RFC3986 specifies the components of a Uniform Resource Identifier (URI). + * A portion of the ABNFs are repeated here: + * + * URI-reference = URI + * / relative-ref + * + * URI = scheme ":" hier-part [ "?" query ] [ "#" fragment ] + * + * relative-ref = relative-part [ "?" query ] [ "#" fragment ] + * + * hier-part = "//" authority path-abempty + * / path-absolute + * / path-rootless + * / path-empty + * + * relative-part = "//" authority path-abempty + * / path-absolute + * / path-noscheme + * / path-empty + * + * authority = [ userinfo "@" ] host [ ":" port ] + * + * So, a URL has the following major components: + * + * scheme + * The name of a method used to interpret the rest of + * the URL. Examples: "http", "https", "mailto", "file'. + * + * authority + * The name of the authority governing the URL's name + * space. Examples: "example.com", "user@example.com", + * "example.com:80", "user:password@example.com:80". + * + * The authority may include a host name, port number, + * user name, and password. + * + * The host may be a name, an IPv4 numeric address, or + * an IPv6 numeric address. + * + * path + * The hierarchical path to the URL's resource. + * Examples: "/index.htm", "/scripts/page.php". + * + * query + * The data for a query. Examples: "?search=google.com". + * + * fragment + * The name of a secondary resource relative to that named + * by the path. Examples: "#section1", "#header". + * + * An "absolute" URL must include a scheme and path. The authority, query, + * and fragment components are optional. + * + * A "relative" URL does not include a scheme and must include a path. The + * authority, query, and fragment components are optional. + * + * This function splits the $url argument into the following components + * and returns them in an associative array. Keys to that array include: + * + * "scheme" The scheme, such as "http". + * "host" The host name, IPv4, or IPv6 address. + * "port" The port number. + * "user" The user name. + * "pass" The user password. + * "path" The path, such as a file path for "http". + * "query" The query. + * "fragment" The fragment. + * + * One or more of these may not be present, depending upon the URL. + * + * Optionally, the "user", "pass", "host" (if a name, not an IP address), + * "path", "query", and "fragment" may have percent-encoded characters + * decoded. The "scheme" and "port" cannot include percent-encoded + * characters and are never decoded. Decoding occurs after the URL has + * been parsed. + * + * Parameters: + * url the URL to parse. + * + * decode an optional boolean flag selecting whether + * to decode percent encoding or not. Default = TRUE. + * + * Return values: + * the associative array of URL parts, or FALSE if the URL is + * too malformed to recognize any parts. + */ +function split_url( $url, $decode=FALSE) +{ + // Character sets from RFC3986. + $xunressub = 'a-zA-Z\d\-._~\!$&\'()*+,;='; + $xpchar = $xunressub . ':@% '; + + // Scheme from RFC3986. + $xscheme = '([a-zA-Z][a-zA-Z\d+-.]*)'; + + // User info (user + password) from RFC3986. + $xuserinfo = '(([' . $xunressub . '%]*)' . + '(:([' . $xunressub . ':%]*))?)'; + + // IPv4 from RFC3986 (without digit constraints). + $xipv4 = '(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'; + + // IPv6 from RFC2732 (without digit and grouping constraints). + $xipv6 = '(\[([a-fA-F\d.:]+)\])'; + + // Host name from RFC1035. Technically, must start with a letter. + // Relax that restriction to better parse URL structure, then + // leave host name validation to application. + $xhost_name = '([a-zA-Z\d-.%]+)'; + + // Authority from RFC3986. Skip IP future. + $xhost = '(' . $xhost_name . '|' . $xipv4 . '|' . $xipv6 . ')'; + $xport = '(\d*)'; + $xauthority = '((' . $xuserinfo . '@)?' . $xhost . + '?(:' . $xport . ')?)'; + + // Path from RFC3986. Blend absolute & relative for efficiency. + $xslash_seg = '(/[' . $xpchar . ']*)'; + $xpath_authabs = '((//' . $xauthority . ')((/[' . $xpchar . ']*)*))'; + $xpath_rel = '([' . $xpchar . ']+' . $xslash_seg . '*)'; + $xpath_abs = '(/(' . $xpath_rel . ')?)'; + $xapath = '(' . $xpath_authabs . '|' . $xpath_abs . + '|' . $xpath_rel . ')'; + + // Query and fragment from RFC3986. + $xqueryfrag = '([' . $xpchar . '/?' . ']*)'; + + // URL. + $xurl = '^(' . $xscheme . ':)?' . $xapath . '?' . + '(\?' . $xqueryfrag . ')?(#' . $xqueryfrag . ')?$'; + + + // Split the URL into components. + if ( !preg_match( '!' . $xurl . '!', $url, $m ) ) + return FALSE; + + if ( !empty($m[2]) ) $parts['scheme'] = strtolower($m[2]); + + if ( !empty($m[7]) ) { + if ( isset( $m[9] ) ) $parts['user'] = $m[9]; + else $parts['user'] = ''; + } + if ( !empty($m[10]) ) $parts['pass'] = $m[11]; + + if ( !empty($m[13]) ) $h=$parts['host'] = $m[13]; + else if ( !empty($m[14]) ) $parts['host'] = $m[14]; + else if ( !empty($m[16]) ) $parts['host'] = $m[16]; + else if ( !empty( $m[5] ) ) $parts['host'] = ''; + if ( !empty($m[17]) ) $parts['port'] = $m[18]; + + if ( !empty($m[19]) ) $parts['path'] = $m[19]; + else if ( !empty($m[21]) ) $parts['path'] = $m[21]; + else if ( !empty($m[25]) ) $parts['path'] = $m[25]; + + if ( !empty($m[27]) ) $parts['query'] = $m[28]; + if ( !empty($m[29]) ) $parts['fragment']= $m[30]; + + if ( !$decode ) + return $parts; + if ( !empty($parts['user']) ) + $parts['user'] = rawurldecode( $parts['user'] ); + if ( !empty($parts['pass']) ) + $parts['pass'] = rawurldecode( $parts['pass'] ); + if ( !empty($parts['path']) ) + $parts['path'] = rawurldecode( $parts['path'] ); + if ( isset($h) ) + $parts['host'] = rawurldecode( $parts['host'] ); + if ( !empty($parts['query']) ) + $parts['query'] = rawurldecode( $parts['query'] ); + if ( !empty($parts['fragment']) ) + $parts['fragment'] = rawurldecode( $parts['fragment'] ); + return $parts; +} + + +/** + * This function joins together URL components to form a complete URL. + * + * RFC3986 specifies the components of a Uniform Resource Identifier (URI). + * This function implements the specification's "component recomposition" + * algorithm for combining URI components into a full URI string. + * + * The $parts argument is an associative array containing zero or + * more of the following: + * + * "scheme" The scheme, such as "http". + * "host" The host name, IPv4, or IPv6 address. + * "port" The port number. + * "user" The user name. + * "pass" The user password. + * "path" The path, such as a file path for "http". + * "query" The query. + * "fragment" The fragment. + * + * The "port", "user", and "pass" values are only used when a "host" + * is present. + * + * The optional $encode argument indicates if appropriate URL components + * should be percent-encoded as they are assembled into the URL. Encoding + * is only applied to the "user", "pass", "host" (if a host name, not an + * IP address), "path", "query", and "fragment" components. The "scheme" + * and "port" are never encoded. When a "scheme" and "host" are both + * present, the "path" is presumed to be hierarchical and encoding + * processes each segment of the hierarchy separately (i.e., the slashes + * are left alone). + * + * The assembled URL string is returned. + * + * Parameters: + * parts an associative array of strings containing the + * individual parts of a URL. + * + * encode an optional boolean flag selecting whether + * to do percent encoding or not. Default = true. + * + * Return values: + * Returns the assembled URL string. The string is an absolute + * URL if a scheme is supplied, and a relative URL if not. An + * empty string is returned if the $parts array does not contain + * any of the needed values. + */ +function join_url( $parts, $encode=FALSE) +{ + if ( $encode ) + { + if ( isset( $parts['user'] ) ) + $parts['user'] = rawurlencode( $parts['user'] ); + if ( isset( $parts['pass'] ) ) + $parts['pass'] = rawurlencode( $parts['pass'] ); + if ( isset( $parts['host'] ) && + !preg_match( '!^(\[[\da-f.:]+\]])|([\da-f.:]+)$!ui', $parts['host'] ) ) + $parts['host'] = rawurlencode( $parts['host'] ); + if ( !empty( $parts['path'] ) ) + $parts['path'] = preg_replace( '!%2F!ui', '/', + rawurlencode( $parts['path'] ) ); + if ( isset( $parts['query'] ) ) + $parts['query'] = rawurlencode( $parts['query'] ); + if ( isset( $parts['fragment'] ) ) + $parts['fragment'] = rawurlencode( $parts['fragment'] ); + } + + $url = ''; + if ( !empty( $parts['scheme'] ) ) + $url .= $parts['scheme'] . ':'; + if ( isset( $parts['host'] ) ) + { + $url .= '//'; + if ( isset( $parts['user'] ) ) + { + $url .= $parts['user']; + if ( isset( $parts['pass'] ) ) + $url .= ':' . $parts['pass']; + $url .= '@'; + } + if ( preg_match( '!^[\da-f]*:[\da-f.:]+$!ui', $parts['host'] ) ) + $url .= '[' . $parts['host'] . ']'; // IPv6 + else + $url .= $parts['host']; // IPv4 or name + if ( isset( $parts['port'] ) ) + $url .= ':' . $parts['port']; + if ( !empty( $parts['path'] ) && $parts['path'][0] != '/' ) + $url .= '/'; + } + if ( !empty( $parts['path'] ) ) + $url .= $parts['path']; + if ( isset( $parts['query'] ) ) + $url .= '?' . $parts['query']; + if ( isset( $parts['fragment'] ) ) + $url .= '#' . $parts['fragment']; + return $url; +} + +/** + * This function encodes URL to form a URL which is properly + * percent encoded to replace disallowed characters. + * + * RFC3986 specifies the allowed characters in the URL as well as + * reserved characters in the URL. This function replaces all the + * disallowed characters in the URL with their repective percent + * encodings. Already encoded characters are not encoded again, + * such as '%20' is not encoded to '%2520'. + * + * Parameters: + * url the url to encode. + * + * Return values: + * Returns the encoded URL string. + */ +function encode_url($url) { + $reserved = array( + ":" => '!%3A!ui', + "/" => '!%2F!ui', + "?" => '!%3F!ui', + "#" => '!%23!ui', + "[" => '!%5B!ui', + "]" => '!%5D!ui', + "@" => '!%40!ui', + "!" => '!%21!ui', + "$" => '!%24!ui', + "&" => '!%26!ui', + "'" => '!%27!ui', + "(" => '!%28!ui', + ")" => '!%29!ui', + "*" => '!%2A!ui', + "+" => '!%2B!ui', + "," => '!%2C!ui', + ";" => '!%3B!ui', + "=" => '!%3D!ui', + "%" => '!%25!ui', + ); + + $url = rawurlencode($url); + $url = preg_replace(array_values($reserved), array_keys($reserved), $url); + return $url; +} + +?> \ No newline at end of file diff --git a/sites/all/modules/job_scheduler/CHANGELOG.txt b/sites/all/modules/job_scheduler/CHANGELOG.txt new file mode 100644 index 0000000000000000000000000000000000000000..053eb080964e357046bcfe51e8ef70df36e9ae32 --- /dev/null +++ b/sites/all/modules/job_scheduler/CHANGELOG.txt @@ -0,0 +1,33 @@ + +Job Scheduler 7.x xxxxxxxxxxxxxxxxxxxxxxx +----------------------------------------- + +- Jose Reyero: Auto schedule jobs. +- Jose Reyero: Reschedule jobs from hook implementation. +- Jose Reyero: Provide crontab style scheduling. +- Jose Reyero: Provide triggers. + +Job Scheduler 7.x 2.0 Alpha 1, 2010-09-29 +----------------------------------------- + +- #922702: Fix Drupal Queue integration. + Note: JobScheduler API has changed significantly, API users must declare + callbacks with hook_cron_job_scheduler_info() and the call signature for + managing jobs has changed. Please review README.txt. +- Upgrade to Drupal 7. + +Job Scheduler 6.x 1.0 Beta 3, 2010-09-14 +---------------------------------------- + +- Fix notice. + +Job Scheduler 6.x 1.0 Beta 2, 2010-09-12 +---------------------------------------- + +- Replace FEEDS_REQUEST_TIME with JOB_SCHEDULER_REQUEST_TIME. +- Fix indexes. + +Job Scheduler 6.x 1.0 Beta 1, 2010-09-11 +---------------------------------------- + +- Initial commit: schedule execution at predetermined time, periodic scheduling. diff --git a/sites/all/modules/job_scheduler/JobScheduler.inc b/sites/all/modules/job_scheduler/JobScheduler.inc new file mode 100644 index 0000000000000000000000000000000000000000..2d3d405b2c9495f3549d3816129b9b36135d77a5 --- /dev/null +++ b/sites/all/modules/job_scheduler/JobScheduler.inc @@ -0,0 +1,226 @@ +<?php + +/** + * @file + * JobScheduler class. + */ + +/** + * Use to make Job Scheduler exceptions identifiable by type. + */ +class JobSchedulerException extends Exception {} + +/** + * Manage scheduled jobs. + */ +class JobScheduler { + /** + * The name of this scheduler. + */ + protected $name; + + /** + * Produces a single instance of JobScheduler for a schedule name. + */ + public static function get($name) { + static $schedulers; + // Instantiante a new scheduler for $name if we haven't done so yet. + if (!isset($schedulers[$name])) { + $class = variable_get('job_scheduler_class_' . $name, 'JobScheduler'); + $schedulers[$name] = new $class($name); + } + return $schedulers[$name]; + } + + /** + * Creates a JobScheduler object. + */ + protected function __construct($name) { + $this->name = $name; + } + + /** + * Returns scheduler info. + * + * @see hook_cron_job_scheduler_info() + * + * @throws JobSchedulerException. + */ + public function info() { + if ($info = job_scheduler_info($this->name)) { + return $info; + } + throw new JobSchedulerException('Could not find Job Scheduler cron information for ' . check_plain($this->name)); + } + + /** + * Add a job to the schedule, replace any existing job. + * + * A job is uniquely identified by $job = array(type, id). + * + * @param $job + * An array that must contain the following keys: + * 'type' - A string identifier of the type of job. + * 'id' - A numeric identifier of the job. + * 'period' - The time when the task should be executed. + * 'periodic' - True if the task should be repeated periodically. + * + * @code + * function worker_callback($job) { + * // Work off job. + * // Set next time to be called. If this portion of the code is not + * // reached for some reason, the scheduler will keep periodically invoking + * // the callback() with the period value initially specified. + * $scheduler->set($job); + * } + * @endcode + */ + public function set($job) { + $job['name'] = $this->name; + $job['last'] = REQUEST_TIME; + if (!empty($job['crontab'])) { + $crontab = new JobSchedulerCronTab($job['crontab']); + $job['next'] = $crontab->nextTime(REQUEST_TIME); + } + else { + $job['next'] = REQUEST_TIME + $job['period']; + } + $job['scheduled'] = 0; + $this->remove($job); + drupal_write_record('job_schedule', $job); + } + + /** + * Reserve a job. + */ + protected function reserve($job) { + $job['name'] = $this->name; + $job['scheduled'] = + $job['last'] = REQUEST_TIME; + $job['next'] = $job['period'] + REQUEST_TIME; + drupal_write_record('job_schedule', $job, array('name', 'type', 'id')); + } + + /** + * Remove a job from the schedule, replace any existing job. + * + * A job is uniquely identified by $job = array(type, id). + */ + public function remove($job) { + db_delete('job_schedule') + ->condition('name', $this->name) + ->condition('type', $job['type']) + ->condition('id', isset($job['id']) ? $job['id'] : 0) + ->execute(); + } + + /** + * Remove all jobs for a given type. + */ + public function removeAll($type) { + db_delete('job_schedule') + ->condition('name', $this->name) + ->condition('type', $type) + ->execute(); + } + + /** + * Dispatches a job. + * + * Executes a worker callback or if schedule declares a queue name, queues a + * job for execution. + * + * @param $job + * A $job array as passed into set() or read from job_schedule table. + * + * @throws Exception + * Exceptions thrown by code called by this method are passed on. + */ + public function dispatch($job) { + $info = $this->info(); + if (!$job['periodic']) { + $this->remove($job); + } + if (!empty($info['queue name'])) { + if (DrupalQueue::get($info['queue name'])->createItem($job)) { + $this->reserve($job); + } + } + else { + $this->execute($job); + } + } + + /** + * Executes a job that + * + * @param $job + * A $job array as passed into set() or read from job_schedule table. + * + * @throws Exception + * Exceptions thrown by code called by this method are passed on. + */ + public function execute($job) { + $info = $this->info(); + // If the job is periodic, re-schedule it before calling the worker, just in case + if ($job['periodic']) { + $job['last'] = REQUEST_TIME; + $this->reschedule($job); + } + if (function_exists($info['worker callback'])) { + call_user_func($info['worker callback'], $job); + } + else { + // @todo If worker doesn't exist anymore we should do something about it, remove and throw exception? + $this->remove($job); + throw new JobSchedulerException('Could not find worker callback function: ' . $info['worker callback']); + } + } + + /** + * Re-schedule a job if intended to run again + * + * (If cannot determine the next time, drop the job) + */ + public function reschedule($job) { + $job['scheduled'] = 0; + if (!empty($job['crontab'])) { + $crontab = new JobSchedulerCronTab($job['crontab']); + $job['next'] = $crontab->nextTime($job['last']); + } + else { + $job['next'] = $job['last'] + $job['period']; + } + + if ($job['next']) { + drupal_write_record('job_schedule', $job, array('item_id')); + } + else { + // If no next time, it may mean it wont run again the next year (crontab) + $this->remove($job); + } + } + + /** + * Check whether a job exists in the queue and update its parameters if so + */ + public function check($job) { + $job += array('id' => 0, 'period' => 0, 'crontab' => ''); + $existing = db_select('job_schedule') + ->fields('job_schedule') + ->condition('name', $this->name) + ->condition('type', $job['type']) + ->condition('id', $job['id']) + ->execute() + ->fetchAssoc(); + // If existing, and changed period or crontab, we need to reschedule the job + if ($existing) { + if ($job['period'] != $existing['period'] || $job['crontab'] != $existing['crontab']) { + $existing['period'] = $job['period']; + $existing['crontab'] = $job['crontab']; + $this->reschedule($existing); + } + return $existing; + } + } +} diff --git a/sites/all/modules/job_scheduler/JobSchedulerCronTab.inc b/sites/all/modules/job_scheduler/JobSchedulerCronTab.inc new file mode 100644 index 0000000000000000000000000000000000000000..cf97e1f89843a60a69fe943c72bdfdce7b6d245e --- /dev/null +++ b/sites/all/modules/job_scheduler/JobSchedulerCronTab.inc @@ -0,0 +1,279 @@ +<?php + +/** + * @file + * JobSchedulerCronTab class. + */ + +/** + * Jose's cron tab parser = Better try only simple crontab strings. + * + * Usage: + * // Run 23 minutes after midn, 2am, 4am ..., everyday + * $crontab = new JobSchedulerCronTab('23 0-23/2 * * *'); + * // When this needs to run next, from current time? + * $next_time = $crontab->nextTime(time()); + * + * I hate Sundays. + */ +class JobSchedulerCronTab { + // Original crontab elements + public $crontab; + // Parsed numeric values indexed by type + public $cron; + + /** + * Constructor + * + * About crontab strings, see all about possible formats + * http://linux.die.net/man/5/crontab + * + * @param $crontab string + * Crontab text line: minute hour day-of-month month day-of-week + */ + public function __construct($crontab) { + $this->crontab = $crontab; + $this->cron = is_array($crontab) ? $this->values($crontab) : $this->parse($crontab); + } + + /** + * Parse full crontab string into an array of type => values + * + * Note this one is static and can be used to validate values + */ + public static function parse($crontab) { + // Crontab elements, names match PHP date indexes (getdate) + $keys = array('minutes', 'hours', 'mday', 'mon', 'wday'); + // Replace multiple spaces by single space + $crontab = preg_replace('/(\s+)/', ' ', $crontab); + // Expand into elements and parse all + $values = explode(' ', trim($crontab)); + return self::values($values); + } + + /** + * Parse array of values, check whether this is valid + */ + public static function values($array) { + if (count($array) == 5) { + $values = array_combine(array('minutes', 'hours', 'mday', 'mon', 'wday'), array_map('trim', $array)); + $elements = array(); + foreach ($values as $type => $string) { + $elements[$type] = self::parseElement($type, $string, TRUE); + } + // Return only if we have the right number of elements + // Dangerous means works running every second or things like that. + if (count(array_filter($elements)) == 5) { + return $elements; + } + } + return NULL; + } + + /** + * Find the next occurrence within the next year as unix timestamp + * + * @param $start_time timestamp + * Starting time + */ + public function nextTime($start_time = NULL, $limit = 366) { + $start_time = isset($start_time) ? $start_time : time(); + $start_date = getdate($start_time); // Get minutes, hours, mday, wday, mon, year + if ($date = $this->nextDate($start_date, $limit)) { + return mktime($date['hours'], $date['minutes'], 0, $date['mon'], $date['mday'], $date['year']); + } + else { + return 0; + } + } + + /** + * Find the next occurrence within the next year as a date array, + * + * @see getdate() + * + * @param $date + * Date array with: 'mday', 'mon', 'year', 'hours', 'minutes' + */ + public function nextDate($date, $limit = 366) { + $date['seconds'] = 0; + // It is possible that the current date doesn't match + if ($this->checkDay($date) && ($nextdate = $this->nextHour($date))) { + return $nextdate; + } + elseif ($nextdate = $this->nextDay($date, $limit)) { + return $nextdate; + } + else { + return FALSE; + } + } + + /** + * Check whether date's day is a valid one + */ + protected function checkDay($date) { + foreach (array('wday', 'mday', 'mon') as $key) { + if (!in_array($date[$key], $this->cron[$key])) { + return FALSE; + } + } + return TRUE; + } + + /** + * Find the next day from date that matches with cron parameters + * + * Maybe it's possible that it's within the next years, maybe no day of a year matches all conditions. + * However, to prevent infinite loops we restrict it to the next year. + */ + protected function nextDay($date, $limit = 366) { + $i = 0; // Safety check, we love infinite loops... + while ($i++ <= $limit) { + // This should fix values out of range, like month > 12, day > 31.... + // So we can trust we get the next valid day, can't we? + $time = mktime(0, 0, 0, $date['mon'], $date['mday'] + 1, $date['year']); + $date = getdate($time); + if ($this->checkDay($date)) { + $date['hours'] = reset($this->cron['hours']); + $date['minutes'] = reset($this->cron['minutes']); + return $date; + } + } + } + /** + * Find the next available hour within the same day + */ + protected function nextHour($date) { + $cron = $this->cron; + while ($cron['hours']) { + $hour = array_shift($cron['hours']); + // Current hour; next minute. + if ($date['hours'] == $hour) { + foreach ($cron['minutes'] as $minute) { + if ($date['minutes'] < $minute) { + $date['hours'] = $hour; + $date['minutes'] = $minute; + return $date; + } + } + } + // Next hour; first avaiable minute. + elseif ($date['hours'] < $hour) { + $date['hours'] = $hour; + $date['minutes'] = reset($cron['minutes']); + return $date; + } + } + return FALSE; + } + + /** + * Parse each text element. Recursive up to some point... + */ + protected static function parseElement($type, $string, $translate = FALSE) { + $string = trim($string); + if ($translate) { + $string = self::translateNames($type, $string); + } + if ($string === '*') { + // This means all possible values, return right away, no need to double check + return self::possibleValues($type); + } + elseif (strpos($string, '/')) { + // Multiple. Example */2, for weekday will expand into 2, 4, 6 + list($values, $multiple) = explode('/', $string); + $values = self::parseElement($type, $values); + foreach ($values as $value) { + if (!($value % $multiple)) { + $range[] = $value; + } + } + } + elseif (strpos($string, ',')) { + // Now process list parts, expand into items, process each and merge back + $list = explode(',', $string); + $range = array(); + foreach ($list as $item) { + if ($values = self::parseElement($type, $item)) { + $range = array_merge($range, $values); + } + } + } + elseif (strpos($string, '-')) { + // This defines a range. Example 1-3, will expand into 1,2,3 + list($start, $end) = explode('-', $string); + // Double check the range is within possible values + $range = range($start, $end); + } + elseif (is_numeric($string)) { + // This looks like a single number, double check it's int + $range = array((int)$string); + } + + // Return unique sorted values and double check they're within possible values + if (!empty($range)) { + $range = array_intersect(array_unique($range), self::possibleValues($type)); + sort($range); + // Sunday validation. We need cron values to match PHP values, thus week day 7 is not allowed, must be 0 + if ($type == 'wday' && in_array(7, $range)) { + array_pop($range); + array_unshift($range, 0); + } + return $range; + } + else { + // No match found for this one, will produce an error with validation + return array(); + } + } + + /** + * Get values for each type + */ + public static function possibleValues($type) { + switch ($type) { + case 'minutes': + return range(0, 59); + case 'hours': + return range(0, 23); + case 'mday': + return range(1, 31); + case 'mon': + return range(1, 12); + case 'wday': + // These are PHP values, not *nix ones + return range(0, 6); + + } + } + + /** + * Replace element names by values + */ + public static function translateNames($type, $string) { + switch ($type) { + case 'wday': + $replace = array_merge( + // Tricky, tricky, we need sunday to be zero at the beginning of a range, but 7 at the end + array('-sunday' => '-7', '-sun' => '-7', 'sunday-' => '0-', 'sun-' => '0-'), + array_flip(array('sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday')), + array_flip(array('sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat')) + ); + break; + case 'mon': + $replace = array_merge( + array_flip(array('nomonth1', 'january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december')), + array_flip(array('nomonth2', 'jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec')), + array('sept' => 9) + ); + break; + } + if (empty($replace)) { + return $string; + } + else { + return strtr($string, $replace); + } + } +} diff --git a/sites/all/modules/job_scheduler/LICENSE.txt b/sites/all/modules/job_scheduler/LICENSE.txt new file mode 100644 index 0000000000000000000000000000000000000000..d159169d1050894d3ea3b98e1c965c4058208fe1 --- /dev/null +++ b/sites/all/modules/job_scheduler/LICENSE.txt @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + <signature of Ty Coon>, 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/sites/all/modules/job_scheduler/README.txt b/sites/all/modules/job_scheduler/README.txt new file mode 100644 index 0000000000000000000000000000000000000000..63c79f5a71d7257747128d0bb104c850932060b6 --- /dev/null +++ b/sites/all/modules/job_scheduler/README.txt @@ -0,0 +1,132 @@ + +Job Scheduler +============= + +Simple API for scheduling tasks once at a predetermined time or periodically at +a fixed interval. + + +Usage +===== + +Declare scheduler. + + function example_cron_job_scheduler_info() { + $schedulers = array(); + $schedulers['example_unpublish'] = array( + 'worker callback' => 'example_unpublish_nodes', + ); + return $schedulers; + } + +Add a job. + + $job = array( + 'type' => 'story', + 'id' => 12, + 'period' => 3600, + 'periodic' => TRUE, + ); + JobScheduler::get('example_unpublish')->set($job); + +Work off a job. + + function example_unpublish_nodes($job) { + // Do stuff. + } + +Remove a job. + + $job = array( + 'type' => 'story', + 'id' => 12, + ); + JobScheduler::get('example_unpublish')->remove($job); + +Optionally jobs can declared together with a schedule in a hook_cron_job_scheduler_info(). + + function example_cron_job_scheduler_info() { + $schedulers = array(); + $schedulers['example_unpublish'] = array( + 'worker callback' => 'example_unpublish_nodes', + 'jobs' => array( + array('type' => 'story', 'id' => 12, 'period' => 3600, 'periodic' => TRUE), + ) + ); + return $schedulers; + } + +Jobs can have a 'crontab' instead of a period. Crontab syntax are Unix-like formatted crontab lines. +Example of job with crontab. + + // This will create a job that will be triggered from monday to friday, from january to july, every two hours + function example_cron_job_scheduler_info() { + $schedulers = array(); + $schedulers['example_unpublish'] = array( + 'worker callback' => 'example_unpublish_nodes', + 'jobs' => array( + array('type' => 'story', 'id' => 12, 'crontab' => '0 */2 * january-july mon-fri', 'periodic' => TRUE), + ) + ); + return $schedulers; + } + +Read more about crontab syntax, http://linux.die.net/man/5/crontab + +Drupal Queue integration +======================== + +Optionally, at the scheduled time Job Scheduler can queue a job for execution, +rather than executing the job directly. This is useful when many jobs need to +be executed or when the job's expected execution time is very long. + +More information on Drupal Queue: http://api.drupal.org/api/group/queue/7 + +Instead of declaring a worker callback, declare a queue name. + + function example_cron_job_scheduler_info() { + $schedulers = array(); + $schedulers['example_unpublish'] = array( + 'queue name' => 'example_unpublish_queue', + ); + return $schedulers; + } + +This of course assumes that you have declared a queue. Notice how in this +pattern the queue callback contains the actual worker callback. + + function example_cron_queue_info() { + $schedulers = array(); + $schedulers['example_unpublish_queue'] = array( + 'worker callback' => 'example_unpublish_nodes', + ); + return $schedulers; + } + + +Work off a job: when using a queue, Job Scheduler reserves a job for one hour +giving the queue time to work off a job before it reschedules it. This means +that the worker callback needs to reset the job's schedule flag in order to +allow renewed scheduling. + + function example_unpublish_nodes($job) { + // Do stuff. + // Set the job again so that its reserved flag is reset. + JobScheduler::get('example_unpublish')->set($job); + } + +Example +======= + +See Feeds module. + + +Hidden settings +=============== + +Hidden settings are variables that you can define by adding them to the $conf +array in your settings.php file. + +Name: 'job_scheduler_class_' . $name +Default: 'JobScheduler' +Description: The class to use for managing a particular schedule. diff --git a/sites/all/modules/job_scheduler/job_scheduler.api.php b/sites/all/modules/job_scheduler/job_scheduler.api.php new file mode 100644 index 0000000000000000000000000000000000000000..8bd85b0531365b75b74116d37ef3fa97f62fa37f --- /dev/null +++ b/sites/all/modules/job_scheduler/job_scheduler.api.php @@ -0,0 +1,50 @@ +<?php + +/** + * @file + * API documentation for hooks. + */ + +/** + * Declare job scheduling holding items that need to be run periodically. + * + * @return + * An associative array where the key is the queue name and the value is + * again an associative array. Possible keys are: + * - 'worker callback': The name of the function to call. It will be called + * at schedule time. + * - 'queue name': The name of the queue to use to queue this task. Must + * contain a valid queue name, declared by hook_cron_queue_info(). + * If queue name is given, worker callback will be ignored. + * + * @see hook_cron_job_scheduler_info_alter() + * @see hook_cron_queue_info() + * @see hook_cron_queue_info_alter() + */ +function hook_cron_job_scheduler_info() { + $info = array(); + $info['example_reset'] = array( + 'worker callback' => 'example_cache_clear_worker', + ); + $info['example_import'] = array( + 'queue name' => 'example_import_queue', + ); + return $info; +} + +/** + * Alter cron queue information before cron runs. + * + * Called by drupal_cron_run() to allow modules to alter cron queue settings + * before any jobs are processesed. + * + * @param array $info + * An array of cron schedule information. + * + * @see hook_cron_queue_info() + * @see drupal_cron_run() + */ +function hook_cron_job_scheduler_info_alter(&$info) { + // Replace the default callback 'example_cache_clear_worker' + $info['example_reset']['worker callback'] = 'my_custom_reset'; +} diff --git a/sites/all/modules/job_scheduler/job_scheduler.info b/sites/all/modules/job_scheduler/job_scheduler.info new file mode 100644 index 0000000000000000000000000000000000000000..e009aa2947ba61c41a3534f09348a8b7f3f359b6 --- /dev/null +++ b/sites/all/modules/job_scheduler/job_scheduler.info @@ -0,0 +1,15 @@ +name = Job Scheduler +description = Scheduler API +files[] = job_scheduler.module +files[] = job_scheduler.install +files[] = JobScheduler.inc +files[] = JobSchedulerCronTab.inc +core = 7.x +php = 5.2 + +; Information added by drupal.org packaging script on 2012-05-08 +version = "7.x-2.0-alpha3" +core = "7.x" +project = "job_scheduler" +datestamp = "1336466457" + diff --git a/sites/all/modules/job_scheduler/job_scheduler.install b/sites/all/modules/job_scheduler/job_scheduler.install new file mode 100644 index 0000000000000000000000000000000000000000..7e62a46770e087f46114fd983a2d6397c0e21bfe --- /dev/null +++ b/sites/all/modules/job_scheduler/job_scheduler.install @@ -0,0 +1,180 @@ +<?php + +/** + * @file + * Schema definitions install/update/uninstall hooks. + */ + +/** + * Implements hook_schema(). + */ +function job_scheduler_schema() { + $schema = array(); + $schema['job_schedule'] = array( + 'description' => 'Schedule of jobs to be executed.', + 'fields' => array( + 'item_id' => array( + 'type' => 'serial', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'Primary Key: Unique item ID.', + ), + 'name' => array( + 'type' => 'varchar', + 'length' => 128, + 'not null' => TRUE, + 'default' => '', + 'description' => 'Name of the schedule.', + ), + 'type' => array( + 'type' => 'varchar', + 'length' => 128, + 'not null' => TRUE, + 'default' => '', + 'description' => 'Type identifier of the job.', + ), + 'id' => array( + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'unsigned' => TRUE, + 'description' => 'Numeric identifier of the job.', + ), + 'period' => array( + 'type' => 'int', + 'unsigned' => TRUE, + 'default' => 0, + 'not null' => TRUE, + 'description' => 'Time period after which job is to be executed.', + ), + 'crontab' => array( + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + 'description' => 'Crontab line in *NIX format.', + ), + 'data' => array( + 'type' => 'blob', + 'not null' => FALSE, + 'size' => 'big', + 'serialize' => TRUE, + 'description' => 'The arbitrary data for the item.', + ), + 'expire' => array( + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'description' => 'Timestamp when job expires.', + ), + 'created' => array( + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'description' => 'Timestamp when the item was created.', + ), + 'last' => array( + 'type' => 'int', + 'unsigned' => TRUE, + 'default' => 0, + 'not null' => TRUE, + 'description' => 'Timestamp when a job was last executed.', + ), + 'periodic' => array( + 'type' => 'int', + 'size' => 'small', + 'unsigned' => TRUE, + 'default' => 0, + 'not null' => TRUE, + 'description' => 'If true job will be automatically rescheduled.', + ), + 'next' => array( + 'type' => 'int', + 'unsigned' => TRUE, + 'default' => 0, + 'not null' => TRUE, + 'description' => 'Timestamp when a job is to be executed (next = last + period), used for fast ordering.', + ), + 'scheduled' => array( + 'type' => 'int', + 'unsigned' => TRUE, + 'default' => 0, + 'not null' => TRUE, + 'description' => 'Timestamp when a job was scheduled. 0 if a job is currently not scheduled.', + ), + ), + 'primary key' => array('item_id'), + 'indexes' => array( + 'name_type_id' => array('name', 'type', 'id'), + 'name_type' => array('name', 'type'), + 'next' => array('next'), + 'scheduled' => array('scheduled'), + ), + ); + return $schema; +} + +/** + * Fix indexes on next. + */ +function job_scheduler_update_6101() { + $ret = array(); + db_drop_index($ret, 'job_schedule', 'last_period'); + db_drop_index($ret, 'job_schedule', 'periodic'); + db_add_index($ret, 'job_schedule', 'next', array('next')); + return $ret; +} + +/** + * Rename 'callback' to 'name' field. + */ +function job_scheduler_update_7100() { + db_drop_index('job_schedule', 'callback_type_id'); + db_drop_index('job_schedule', 'callback_type'); + $spec = array( + 'type' => 'varchar', + 'length' => 128, + 'not null' => TRUE, + 'default' => '', + 'description' => 'Name of the schedule.', + ); + db_change_field('job_schedule', 'callback', 'name', $spec); + db_add_index('job_schedule', 'name_type_id', array('name', 'type', 'id')); + db_add_index('job_schedule', 'name_type', array('name', 'type')); +} + +/** + * Add fields: item_id, crontab, data, expire + */ +function job_scheduler_update_7101() { + $spec = array( + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + 'description' => 'Crontab line in *NIX format.', + ); + db_add_field('job_schedule', 'crontab', $spec); + $spec = array( + 'type' => 'blob', + 'not null' => FALSE, + 'size' => 'big', + 'serialize' => TRUE, + 'description' => 'The arbitrary data for the item.', + ); + db_add_field('job_schedule', 'data', $spec); + $spec = array( + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'description' => 'Timestamp when job expires.', + ); + db_add_field('job_schedule', 'expire', $spec); + $spec = array( + 'type' => 'serial', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'Primary Key: Unique item ID.', + ); + db_add_field('job_schedule', 'item_id', $spec, array('primary key' => array('item_id'))); +} diff --git a/sites/all/modules/job_scheduler/job_scheduler.module b/sites/all/modules/job_scheduler/job_scheduler.module new file mode 100644 index 0000000000000000000000000000000000000000..c28e4957cde5b80c23c6a27f1d8104891c3883d4 --- /dev/null +++ b/sites/all/modules/job_scheduler/job_scheduler.module @@ -0,0 +1,158 @@ +<?php + +/** + * @file + * job scheduler module. + */ + +/** + * Collects and returns scheduler info. + * + * @see hook_cron_job_scheduler_info() + * + * @param $name + * Name of the schedule + * @return array + * Information for the schedule if $name, all the information if not + */ +function job_scheduler_info($name = NULL) { + $info = &drupal_static(__FUNCTION__); + if (!$info) { + $info = module_invoke_all('cron_job_scheduler_info'); + drupal_alter('cron_job_scheduler_info', $info); + } + if ($name) { + return isset($info[$name]) ? $info[$name] : NULL; + } + else { + return $info; + } +} + +/** + * Implements hook_cron(). + */ +function job_scheduler_cron() { + // Reschedule all jobs if requested. + if (variable_get('job_scheduler_rebuild_all', FALSE)) { + foreach (job_scheduler_info() as $name => $info) { + job_scheduler_rebuild_scheduler($name, $info); + } + variable_set('job_scheduler_rebuild_all', FALSE); + return; + } + + // Reschedule stuck periodic jobs after one hour. + db_update('job_schedule') + ->fields(array( + 'scheduled' => 0, + )) + ->condition('scheduled', REQUEST_TIME - 3600, '<') + ->condition('periodic', 1) + ->execute(); + + // Query and dispatch scheduled jobs. + // Process a maximum of 200 jobs in a maximum of 30 seconds. + $start = time(); + $total = + $failed = 0; + $jobs = db_select('job_schedule', NULL, array('fetch' => PDO::FETCH_ASSOC)) + ->fields('job_schedule') + ->condition('scheduled', 0) + ->condition('next', REQUEST_TIME, '<=') + ->orderBy('next', 'ASC') + ->range(0, 200) + ->execute(); + foreach ($jobs as $job) { + try { + JobScheduler::get($job['name'])->dispatch($job); + } + catch (Exception $e) { + watchdog('job_scheduler', $e->getMessage(), array(), WATCHDOG_ERROR); + $failed++; + // Drop jobs that have caused exceptions + JobScheduler::get($job['name'])->remove($job); + } + $total++; + if (time() > ($start + 30)) { + break; + } + } + + // Leave a note on how much time we spent processing. + watchdog('job_scheduler', 'Finished processing scheduled jobs (!time s, !total total, !failed failed).', array('!time' => format_interval(time() - $start), '!total' => $total, '!failed' => $failed)); +} + +/** + * Implements hook_modules_enabled(). + */ +function job_scheduler_modules_enabled($modules) { + job_scheduler_rebuild_all(); +} + +/** + * Implements hook_modules_disabled(). + */ +function job_scheduler_modules_disabled($modules) { + job_scheduler_rebuild_all(); +} + +/** + * Rebuild scheduled information after enable/disable modules + * + * @todo What should we do about missing ones when disabling their module? + */ +function job_scheduler_rebuild_all() { + variable_set('job_scheduler_rebuild_all', TRUE); +} + +/** + * Rebuild a single scheduler + */ +function job_scheduler_rebuild_scheduler($name, $info = NULL) { + $info = $info ? $info : job_scheduler_info($name); + if (!empty($info['jobs'])) { + $scheduler = JobScheduler::get($name); + foreach ($info['jobs'] as $job) { + if (!$scheduler->check($job)) { + $scheduler->set($job); + } + } + } +} + +/** + * Implements hook_cron_queue_info(). + * + * Provide queue worker information for jobs declared in + * hook_cron_job_scheduler_info(). + */ +function job_scheduler_cron_queue_info() { + $queue = array(); + foreach (job_scheduler_info() as $name => $info) { + if (!empty($info['jobs']) && !empty($info['queue name'])) { + $queue[$info['queue name']] = array( + 'worker callback' => 'job_scheduler_cron_queue_worker', + 'time' => 60, // Some reasonable default as we don't know + ); + } + } + return $queue; +} + +/** + * Execute job worker from queue + * + * Providing our own worker has the advantage that we can reschedule the job or take care of cleanup + * Note that as we run the execute() action, the job won't be queued again this time. + */ +function job_scheduler_cron_queue_worker($job) { + try { + JobScheduler::get($job['name'])->execute($job); + } + catch (Exception $e) { + watchdog('job_scheduler', $e->getMessage(), array(), WATCHDOG_ERROR); + // Drop jobs that have caused exceptions + JobScheduler::get($job['name'])->remove($job); + } +} diff --git a/sites/all/modules/job_scheduler/modules/job_scheduler_trigger/README.md b/sites/all/modules/job_scheduler/modules/job_scheduler_trigger/README.md new file mode 100644 index 0000000000000000000000000000000000000000..b7d36fc5a7af9ab02c8beb153fb2df165359afc3 --- /dev/null +++ b/sites/all/modules/job_scheduler/modules/job_scheduler_trigger/README.md @@ -0,0 +1,11 @@ +Drupal Module: Job Scheduler Trigger +==================================== +Extension for Job Scheduler to create timed periodic triggers. + +This module provides a simple UI to configure trigger name and crontab. We provide no actions, though actions created +by other modules can be set to be triggered with these timers. + +Trigger type: job_scheduler +Hook names will be created on the fly for configured triggers as: job_scheduler_1, job_scheduler_2, etc... + +Jose A. Reyero, http://www.developmentseed.org diff --git a/sites/all/modules/job_scheduler/modules/job_scheduler_trigger/job_scheduler_trigger.admin.inc b/sites/all/modules/job_scheduler/modules/job_scheduler_trigger/job_scheduler_trigger.admin.inc new file mode 100644 index 0000000000000000000000000000000000000000..584f662ed505fc5d1fda56cabd63df83074b1776 --- /dev/null +++ b/sites/all/modules/job_scheduler/modules/job_scheduler_trigger/job_scheduler_trigger.admin.inc @@ -0,0 +1,157 @@ +<?php + +/** + * @file + * Job Scheduler Trigger admin pages + */ +function job_scheduler_trigger_admin_overview() { + $base_path = 'admin/config/system/job_scheduler'; + $destination = drupal_get_destination(); + $header = array( + 'title' => t('Title'), + 'crontab' => t('Cron tab'), + 'status' => t('Status'), + 'last' => t('Last'), + 'next' => t('Next'), + 'operations' => t('Operations'), + ); + + $options = array(); + foreach (job_scheduler_trigger_list() as $trigger) { + $next_time = job_scheduler_trigger_next_time($trigger->crontab); + $options[$trigger->trid] = array( + 'title' => check_plain($trigger->title), + 'crontab' => check_plain($trigger->crontab), + 'status' => $trigger->status ? t('Enabled') : t('Disabled'), + 'last' => $trigger->last ? format_date($trigger->last) : t('Never'), + 'next' => $next_time ? format_date($next_time) : t('Never'), + ); + $operations['edit'] = array( + 'title' => t('edit'), + 'href' => $base_path . '/' . $trigger->trid, + 'query' => $destination, + ); + $options[$trigger->trid]['operations'] = array( + 'data' => array( + '#theme' => 'links', + '#links' => $operations, + '#attributes' => array('class' => array('links', 'inline')), + ), + ); + } + + $form['triggers'] = array( + '#theme' => 'table', + '#header' => $header, + '#rows' => $options, + '#empty' => t('No triggers created yet.'), + ); + + $form['add']['#markup'] = l(t('Add a new trigger'), $base_path . '/add', array('query' => $destination)); + + return $form; +} + + +/** + * Edit form for a trigger + */ +function job_scheduler_trigger_edit_form($form, &$form_state, $trigger) { + if (!$trigger) { + $trigger = (object)array('trid' => NULL, 'title' => t('New trigger'), 'crontab' => '* * * * *', 'status' => 0, 'last' => 0); + } + $form['trigger'] = array('#tree' => TRUE); + $form['trigger']['trid'] = array('#type' => 'value', '#value' => $trigger->trid); + $form['trigger']['title'] = array( + '#title' => t('Title'), + '#type' => 'textfield', + '#default_value' => $trigger->title, + '#required' => TRUE, + '#description' => t('This is the name of the trigger that will be created when enabled.'), + ); + $form['trigger']['crontab'] = array( + '#type' => 'textfield', + '#title' => t('Cron tab'), + '#default_value' => $trigger->crontab, + '#required' => TRUE, + '#description' => t('Unix-style crontab line with: <i>minutes</i> <i>hours</i> <i>day-of-month</i> <i>month</i> <i>day-of-week</i>. Read <a href=@man-crontab>more about crontab</a>.', array('@man-crontab' => url('http://linux.die.net/man/5/crontab'))), + ); + $form['trigger']['status'] = array( + '#title' => t('Status'), + '#type' => 'radios', + '#options' => array(t('disabled'), t('enabled')), + '#default_value' => $trigger->status, + '#description' => t('When enabled, a trigger will be created with this crontab and jobs will be scheduled for it. Check everything is OK before enabling the trigger.'), + ); + $form['trigger']['last'] = array( + '#type' => 'item', + '#title' => t('Last triggered'), + '#markup' => format_date($trigger->last), + ); + if ($trigger->trid && $trigger->crontab) { + $next = job_scheduler_trigger_next_time($trigger->crontab); + $form['trigger']['next'] = array( + '#type' => 'item', + '#title' => t('Next trigger'), + '#markup' => $next ? format_date($next) : t('Never in the next year.'), + ); + } + $form['buttons']['update'] = array('#type' => 'submit', '#value' => t('Save')); + + if ($trigger->trid) { + $form['buttons']['delete'] = array('#type' => 'submit', '#value' => t('Delete')); + } + + return $form; +} + +/** + * Validate trigger + */ +function job_scheduler_trigger_edit_form_validate($form, &$form_state) { + $trigger = $form_state['values']['trigger']; + if ($trigger['crontab']) { + if (!JobSchedulerCronTab::parse($trigger['crontab'])) { + form_set_error('trigger][crontab', t('Invalid cron parameters.')); + } + } +} + +/** + * Submit trigger + */ +function job_scheduler_trigger_edit_form_submit($form, &$form_state) { + $op = isset($form_state['values']['op']) ? $form_state['values']['op'] : ''; + $trigger = $form_state['values']['trigger']; + if ($op == t('Save')) { + if (empty($trigger['trid'])) { + // Create and then assign trigger name and create trigger + drupal_write_record('job_scheduler_trigger', $trigger); + $trigger['hook'] = 'job_scheduler_' . $trigger['trid']; + drupal_write_record('job_scheduler_trigger', $trigger, 'trid'); + drupal_set_message(t('A new trigger has been created.')); + } + else { + drupal_write_record('job_scheduler_trigger', $trigger, 'trid'); + drupal_set_message(t('The trigger has been updated.')); + } + } + elseif ($op == t('Delete')) { + db_delete('job_scheduler_trigger') + ->condition('trid', $trigger['trid']) + ->execute(); + drupal_set_message(t('The trigger has been deleted.')); + // @todo Delete scheduled jobs + } + // Synchronize triggers, actions, scheduled jobs, etc.. + actions_synchronize(); + job_scheduler_rebuild_all(); +} + +/** + * Quick calculate next time cron + */ +function job_scheduler_trigger_next_time($crontab) { + $crontab = new JobSchedulerCronTab($crontab); + return $crontab->cron ? $crontab->nextTime(REQUEST_TIME) : 0; +} diff --git a/sites/all/modules/job_scheduler/modules/job_scheduler_trigger/job_scheduler_trigger.info b/sites/all/modules/job_scheduler/modules/job_scheduler_trigger/job_scheduler_trigger.info new file mode 100644 index 0000000000000000000000000000000000000000..ab1a697ae206f811172959ffe4a9600ad90a1876 --- /dev/null +++ b/sites/all/modules/job_scheduler/modules/job_scheduler_trigger/job_scheduler_trigger.info @@ -0,0 +1,12 @@ +name = Job Scheduler Trigger +description = Creates scheduler triggers that fire up at certain days, times +core = 7.x +php = 5.2 +dependencies[] = job_scheduler + +; Information added by drupal.org packaging script on 2012-05-08 +version = "7.x-2.0-alpha3" +core = "7.x" +project = "job_scheduler" +datestamp = "1336466457" + diff --git a/sites/all/modules/job_scheduler/modules/job_scheduler_trigger/job_scheduler_trigger.install b/sites/all/modules/job_scheduler/modules/job_scheduler_trigger/job_scheduler_trigger.install new file mode 100644 index 0000000000000000000000000000000000000000..594b67fd5eebc8f2c8be880a191e8109c5996e06 --- /dev/null +++ b/sites/all/modules/job_scheduler/modules/job_scheduler_trigger/job_scheduler_trigger.install @@ -0,0 +1,54 @@ +<?php + +/** + * Implementation of hook_schema(). + */ +function job_scheduler_trigger_schema() { + $schema['job_scheduler_trigger'] = array( + 'description' => 'Schedule of triggers to be created.', + 'fields' => array( + 'trid' => array( + 'description' => 'Primary Key: unique trigger id.', + 'type' => 'serial', + 'unsigned' => TRUE, + 'not null' => TRUE, + ), + 'hook' => array( + 'type' => 'varchar', + 'length' => 32, + 'not null' => TRUE, + 'default' => '', + 'description' => 'Primary Key: The name of the internal Drupal hook; for example, job_scheduer_100.', + ), + 'title' => array( + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + 'description' => 'Title for the trigger.', + ), + 'crontab' => array( + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + 'description' => 'Crontab line in *NIX format.', + ), + 'status' => array( + 'description' => 'Boolean indicating whether the trigger is active.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 1, + ), + 'last' => array( + 'type' => 'int', + 'unsigned' => TRUE, + 'default' => 0, + 'not null' => TRUE, + 'description' => 'Timestamp when it was lat triggered.', + ), + ), + 'primary key' => array('trid'), + ); + return $schema; +} diff --git a/sites/all/modules/job_scheduler/modules/job_scheduler_trigger/job_scheduler_trigger.module b/sites/all/modules/job_scheduler/modules/job_scheduler_trigger/job_scheduler_trigger.module new file mode 100644 index 0000000000000000000000000000000000000000..f969dc2747b0a9973c3e69d95ad8997b9a960baf --- /dev/null +++ b/sites/all/modules/job_scheduler/modules/job_scheduler_trigger/job_scheduler_trigger.module @@ -0,0 +1,126 @@ +<?php + +/** + * @file + * job scheduler module. + */ + +/** + * Implements hook_help(). + */ +function job_scheduler_trigger_help($path, $arg) { + switch ($path) { + case 'admin/structure/trigger/job_scheduler': + // The first line is the very same text trigger module provides. Do not edit. + $output = '<p>' . t('Triggers are events on your site, such as new content being added or a user logging in. The Trigger module associates these triggers with actions (functional tasks), such as unpublishing content containing certain keywords or e-mailing an administrator. The <a href="@url">Actions settings page</a> contains a list of existing actions and provides the ability to create and configure advanced actions (actions requiring configuration, such as an e-mail address or a list of banned words).', array('@url' => url('admin/config/system/actions'))) . '</p>'; + $output .= '<p>' . t('Below you can assign actions to run on a periodic basis. To create more triggers of this type use the <a href="@url">Job Scheduler configuration</a> page.', array('@url' => url('admin/config/system/job_scheduler'))) . '</p>'; + return $output; + case 'admin/config/system/job_scheduler': + $output = '<p>' . t('You can create any number of jobs that are triggered depending on a crontab.') . '</p>'; + $output .= '<p>' . t('To set actions for these triggers, go to the <a href="@url">Job Scheduler Triggers</a> page.', array('@url' => url('admin/structure/trigger/job_scheduler'))) . '</p>'; + return $output; + } +} + +/** + * Implementation of hook_menu() + */ +function job_scheduler_trigger_menu() { + $items['admin/config/system/job_scheduler'] = array( + 'title' => 'Trigger scheduler', + 'description' => 'Configure timely triggers', + 'page callback' => 'job_scheduler_trigger_admin_overview', + 'access arguments' => array('administer site configuration'), + 'file' => 'job_scheduler_trigger.admin.inc', + ); + $items['admin/config/system/job_scheduler/add'] = array( + 'title' => 'Trigger scheduler', + 'description' => 'Configure timely triggers', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('job_scheduler_trigger_edit_form', NULL), + 'access arguments' => array('administer site configuration'), + 'file' => 'job_scheduler_trigger.admin.inc', + ); + $items['admin/config/system/job_scheduler/%job_scheduler_trigger'] = array( + 'title' => 'Trigger scheduler', + 'description' => 'Configure timely triggers', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('job_scheduler_trigger_edit_form', 4), + 'access arguments' => array('administer site configuration'), + 'file' => 'job_scheduler_trigger.admin.inc', + ); + return $items; +} + +/** + * Implementation of hook_trigger_info(). + */ +function job_scheduler_trigger_trigger_info() { + $triggers = array(); + foreach (job_scheduler_trigger_list() as $trigger) { + $label = $trigger->status ? $trigger->title : $trigger->title . ' ' . t('(Disabled)'); + $triggers['job_scheduler'][$trigger->hook] = array( + 'label' => $label, + ); + } + return $triggers; +} + +/** + * Implementation of hook_job_scheduler_info() + */ +function job_scheduler_trigger_cron_job_scheduler_info() { + foreach (job_scheduler_trigger_list() as $trigger) { + if ($trigger->status) { + $jobs[$trigger->trid] = array('id' => $trigger->trid, 'type' => $trigger->hook, 'crontab' => $trigger->crontab, 'periodic' => TRUE); + } + } + if (!empty($jobs)) { + $scheduler['job_scheduler_trigger'] = array( + 'worker callback' => 'job_scheduler_trigger_worker', + 'jobs' => $jobs, + ); + return $scheduler; + } +} + +/** + * Get job list for job scheduler + */ +function job_scheduler_trigger_list() { + return db_select('job_scheduler_trigger', 't') + ->fields('t') + ->orderBy('status', 'DESC') + ->execute()->fetchAll(); +} + +/** + * Fire up a scheduled trigger + */ +function job_scheduler_trigger_worker($job) { + // Mark last time triggered for the records + db_update('job_scheduler_trigger') + ->fields(array('last' => REQUEST_TIME)) + ->condition('trid', $job['id']) + ->execute(); + if ($aids = trigger_get_assigned_actions($job['type'])) { + $context = array( + 'group' => 'scheduler', + 'hook' => $job['type'], + 'job' => $job, + ); + // Scheduler's object is the job from job_scheduler + $object = (object)$job; + actions_do(array_keys($aids), $object, $context); + } +} + +/** + * Menu loader + */ +function job_scheduler_trigger_load($trid) { + return db_select('job_scheduler_trigger', 't') + ->fields('t') + ->condition('trid', $trid) + ->execute()->fetchObject(); +}