file-watch.js 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. /**
  2. * @node file-watch
  3. * @name File Watch
  4. * @category triggers
  5. * @version 1.0.0
  6. * @description Report files added or changed in a directory since the last run, paired with a schedule trigger for its cadence
  7. * @icon folder-search
  8. */
  9. const configSchema = {
  10. type: 'object',
  11. properties: {
  12. directory: {
  13. type: 'string',
  14. title: 'Directory',
  15. description: 'Absolute path to watch. Not recursive'
  16. },
  17. pattern: {
  18. type: 'string',
  19. title: 'Name Pattern',
  20. description: 'Optional regular expression a file name must match, such as \\.csv$'
  21. },
  22. includeDirectories: {
  23. type: 'boolean',
  24. title: 'Include Directories',
  25. description: 'Report subdirectories as well as files',
  26. default: false
  27. },
  28. emitOnFirstRun: {
  29. type: 'boolean',
  30. title: 'Report Everything On First Run',
  31. description: 'On the very first run there is nothing to compare against. Off records what is there and reports nothing, so adding this to a live workflow does not fire for every file already present',
  32. default: false
  33. },
  34. cursorCollection: {
  35. type: 'string',
  36. title: 'Cursor Collection',
  37. description: 'Collection holding the last-seen state',
  38. default: 'watch_cursors'
  39. }
  40. },
  41. required: ['directory']
  42. };
  43. const inputSchema = {
  44. type: 'object',
  45. properties: {
  46. data: { type: 'any' }
  47. }
  48. };
  49. const outputSchema = {
  50. type: 'object',
  51. properties: {
  52. files: { type: 'array', description: 'Files new or changed since the last run' },
  53. count: { type: 'number' },
  54. isFirstRun: { type: 'boolean', description: 'True when no cursor existed yet' }
  55. }
  56. };
  57. async function execute(config, input, context) {
  58. const directory = config.directory;
  59. if (!directory) {
  60. throw new Error('File Watch: a directory is required');
  61. }
  62. const collection = config.cursorCollection || 'watch_cursors';
  63. const workflowId = (context && context.workflowId) || 'unknown';
  64. const nodeId = (context && context.nodeId) || 'file-watch';
  65. const cursorId = workflowId + ':' + nodeId;
  66. const listing = smartbotic.fs.readdir(directory);
  67. if (!listing || listing.success !== true) {
  68. throw new Error('File Watch: could not read ' + directory + ': ' +
  69. ((listing && listing.error) || 'unknown error'));
  70. }
  71. let matcher = null;
  72. if (config.pattern) {
  73. try {
  74. matcher = new RegExp(config.pattern);
  75. } catch (e) {
  76. throw new Error('File Watch: "' + config.pattern + '" is not a valid pattern: ' + e.message);
  77. }
  78. }
  79. const current = {};
  80. const candidates = [];
  81. for (const entry of listing.entries) {
  82. if (entry.isDirectory && config.includeDirectories !== true) {
  83. continue;
  84. }
  85. if (matcher && !matcher.test(entry.name)) {
  86. continue;
  87. }
  88. // Size and modification time together, because a file rewritten within
  89. // the same second at the same length is not a change worth waking a
  90. // workflow for, and a timestamp alone misses a rewrite that preserves
  91. // mtime granularity.
  92. current[entry.name] = entry.modifiedAt + ':' + entry.size;
  93. candidates.push(entry);
  94. }
  95. const stored = smartbotic.storage.get(collection, cursorId);
  96. // A missing document is the expected shape of a first run. Anything else
  97. // that keeps storage.get from returning the cursor - no read access to
  98. // the collection, a connection error - is a real failure and must not be
  99. // swallowed into a false "first run" that then quietly overwrites state.
  100. const cursorMissing = stored && typeof stored.error === 'string' &&
  101. stored.error.indexOf('Document not found') === 0;
  102. if (stored && stored.found !== true && !cursorMissing) {
  103. throw new Error('File Watch: could not read the cursor for ' + directory + ': ' +
  104. (stored.error || 'unknown storage error'));
  105. }
  106. const isFirstRun = !stored || stored.found !== true;
  107. const previous = (!isFirstRun && stored.document && stored.document.seen) || {};
  108. let changed = [];
  109. if (isFirstRun && config.emitOnFirstRun !== true) {
  110. smartbotic.log.info('File Watch: first run on ' + directory + ', recorded ' +
  111. candidates.length + ' entries without reporting them');
  112. } else {
  113. changed = candidates.filter(function (entry) {
  114. return previous[entry.name] !== current[entry.name];
  115. });
  116. }
  117. const cursor = { seen: current, updatedAt: Date.now(), directory: directory };
  118. const write = isFirstRun
  119. ? smartbotic.storage.insert(collection, cursor, cursorId)
  120. : smartbotic.storage.update(collection, cursorId, cursor);
  121. if (!write || write.success === false) {
  122. throw new Error('File Watch: could not save the cursor for ' + directory + ': ' +
  123. ((write && write.error) || 'unknown storage error'));
  124. }
  125. smartbotic.log.info('File Watch: ' + changed.length + ' new or changed in ' + directory);
  126. return {
  127. files: changed,
  128. count: changed.length,
  129. isFirstRun: isFirstRun
  130. };
  131. }
  132. module.exports = { configSchema, inputSchema, outputSchema, execute };