diff --git a/README.org b/README.org index 1c4a439bf..540c097fa 100644 --- a/README.org +++ b/README.org @@ -278,19 +278,26 @@ organice implements this customization strategy: *** =#+STARTUP:= options - =nologrepeat=: Do not record when reinstating repeating item - +- =logdone=: Create a closing entry when a TODO is marked DONE *** Drawer properties :PROPERTIES: :END: - =logrepeat= and =nologrepeat=: Whether to record when reinstating repeating item - +- =logdone=: Create a closing entry when a TODO is marked DONE + #+BEGIN_EXAMPLE :PROPERTIES: :LOGGING: logrepeat :END: #+END_EXAMPLE +#+BEGIN_EXAMPLE + :PROPERTIES: + :LOGGING: logdone + :END: +#+END_EXAMPLE + ** Themes / Color scheme / Dark Mode / Light Mode :PROPERTIES: diff --git a/src/actions/base.js b/src/actions/base.js index 3e1a7094b..6a855f82d 100644 --- a/src/actions/base.js +++ b/src/actions/base.js @@ -117,6 +117,11 @@ export const setShouldLogIntoDrawer = (shouldLogIntoDrawer) => ({ shouldLogIntoDrawer, }); +export const setShouldLogDone = (shouldLogDone) => ({ + type: 'SET_SHOULD_LOG_DONE', + shouldLogDone, +}); + export const setCloseSubheadersRecursively = (closeSubheadersRecursively) => ({ type: 'SET_CLOSE_SUBHEADERS_RECURSIVELY', closeSubheadersRecursively, diff --git a/src/actions/org.js b/src/actions/org.js index 2886d4c1d..3d1013b2a 100644 --- a/src/actions/org.js +++ b/src/actions/org.js @@ -265,10 +265,11 @@ export const selectHeaderAndOpenParents = (path, headerId) => (dispatch) => { * @param {*} headerId headerId to advance, or null if you want the currently narrowed header. * @param {*} logIntoDrawer false to log state change into body, true to log into :LOGBOOK: drawer. */ -export const advanceTodoState = (headerId, logIntoDrawer) => ({ +export const advanceTodoState = (headerId, logIntoDrawer, logDone) => ({ type: 'ADVANCE_TODO_STATE', headerId, logIntoDrawer, + logDone, dirtying: true, timestamp: new Date(), }); diff --git a/src/components/FileSettingsEditor/components/FileSetting/stylesheet.css b/src/components/FileSettingsEditor/components/FileSetting/stylesheet.css index 2af363834..00ec9a1d2 100644 --- a/src/components/FileSettingsEditor/components/FileSetting/stylesheet.css +++ b/src/components/FileSettingsEditor/components/FileSetting/stylesheet.css @@ -100,4 +100,4 @@ grid-row-start: 1; grid-row-end: span 2; align-self: center; -} \ No newline at end of file +} diff --git a/src/components/OrgFile/OrgFile.integration.test.js b/src/components/OrgFile/OrgFile.integration.test.js index ae6509cee..7d65c82a1 100644 --- a/src/components/OrgFile/OrgFile.integration.test.js +++ b/src/components/OrgFile/OrgFile.integration.test.js @@ -12,7 +12,8 @@ import readFixture from '../../../test_helpers/index'; import rootReducer from '../../reducers/'; import { setPath, parseFile } from '../../actions/org'; -import { setShouldLogIntoDrawer } from '../../actions/base'; +import { setShouldLogIntoDrawer, setShouldLogDone } from '../../actions/base'; +import { timestampForDate } from '../../lib/timestamps.js'; import { Map, Set, fromJS, List } from 'immutable'; import { formatDistanceToNow } from 'date-fns'; @@ -248,6 +249,7 @@ describe('Render all views', () => { }); describe('Tracking TODO state changes', () => { + const date = new Date(); describe('Default settings', () => { test('Does not track TODO state change for repeating todos', () => { expect(queryByText(':LOGBOOK:...')).toBeFalsy(); @@ -262,6 +264,19 @@ describe('Render all views', () => { expect(queryByText(':LOGBOOK:...')).toBeFalsy(); }); }); + test('Does not create log when TODO marked DONE', () => { + expect(queryByText(':LOGBOOK:...')).toBeFalsy(); + expect(store.getState().base.toJS().shouldLogIntoDrawer).toBeFalsy(); + expect(store.getState().base.toJS().shouldLogDone).toBeFalsy(); + + fireEvent.click(queryByText('Top level header')); + expect(queryByText('TODO')).toBeTruthy(); + expect(queryByText('DONE')).toBeFalsy(); + fireEvent.click(queryByText('TODO')); + + expect(queryByText(':LOGBOOK:...')).toBeFalsy(); + }); + describe('Feature enabled', () => { test('Does track TODO state change for repeating todos', () => { expect(store.getState().base.toJS().shouldLogIntoDrawer).toBeFalsy(); @@ -286,6 +301,59 @@ describe('Render all views', () => { expect(queryByText(':LOGBOOK:...')).toBeTruthy(); }); }); + + test('Adds an entry to the logbook when a TODO marked is DONE and logIntoDrawer is selected', () => { + expect(queryByText(':LOGBOOK:...')).toBeFalsy(); + expect(store.getState().base.toJS().shouldLogIntoDrawer).toBeFalsy(); + expect(store.getState().base.toJS().shouldLogDone).toBeFalsy(); + + store.dispatch(setShouldLogIntoDrawer(true)); + store.dispatch(setShouldLogDone(true)); + + expect(store.getState().base.toJS().shouldLogIntoDrawer).toBeTruthy(); + expect(store.getState().base.toJS().shouldLogDone).toBeTruthy(); + + fireEvent.click(queryByText('Top level header')); + expect(queryByText('TODO')).toBeTruthy(); + expect(queryByText('DONE')).toBeFalsy(); + fireEvent.click(queryByText('TODO')); + expect(queryByText('DONE')).toBeTruthy(); + expect(queryByText(':LOGBOOK:...')).toBeTruthy(); + }); + + test('Adds a note to the header when a TODO is marked DONE and logIntoDrawer not selected', () => { + expect(queryByText(':LOGBOOK:...')).toBeFalsy(); + expect(store.getState().base.toJS().shouldLogIntoDrawer).toBeFalsy(); + expect(store.getState().base.toJS().shouldLogDone).toBeFalsy(); + + store.dispatch(setShouldLogDone(true)); + expect(store.getState().base.toJS().shouldLogDone).toBeTruthy(); + + fireEvent.click(queryByText('Top level header')); + expect(queryByText('TODO')).toBeTruthy(); + expect(queryByText('DONE')).toBeFalsy(); + + fireEvent.click(queryByText('TODO')); + expect(queryByText('DONE')).toBeTruthy(); + expect(queryByText(':LOGBOOK:...')).toBeFalsy(); + + expect( + store + .getState() + .org.present.getIn(['files', STATIC_FILE_PREFIX + 'fixtureTestFile.org', 'headers']) + .getIn([2, 'logNotes', 0, 'contents']) + ).toEqual('CLOSED: '); + + const { day, month, startHour, startMinute, year } = store + .getState() + .org.present.getIn(['files', STATIC_FILE_PREFIX + 'fixtureTestFile.org', 'headers']) + .getIn([2, 'logNotes', 1, 'firstTimestamp']) + .toJS(); + const actualDate = timestampForDate(new Date(year, month - 1, day, startHour, startMinute)); + + const expectedDate = timestampForDate(date); + expect(actualDate).toEqual(expectedDate); + }); }); describe('Renders everything starting from an Org file', () => { diff --git a/src/components/OrgFile/OrgFile.unit.test.js b/src/components/OrgFile/OrgFile.unit.test.js index b969b1522..fb832d683 100644 --- a/src/components/OrgFile/OrgFile.unit.test.js +++ b/src/components/OrgFile/OrgFile.unit.test.js @@ -9,7 +9,7 @@ import { import { exportOrg, createRawDescriptionText } from '../../lib/export_org'; import { newHeaderWithTitle } from '../../lib/parse_org'; import readFixture from '../../../test_helpers/index'; -import { noLogRepeatEnabledP } from '../../reducers/org'; +import { noLogRepeatEnabledP, logDoneEnabledP } from '../../reducers/org'; import { fromJS } from 'immutable'; /** @@ -470,6 +470,31 @@ ${description}`; expect(noLogRepeatEnabledP({ state, headerIndex: 7 })).toBe(true); }); }); + describe('"logdone" configuration', () => { + test('Detects "logdone" when set in #+STARTUP as only option', () => { + const testOrgFile = readFixture('schedule_with_logdone'); + const state = parseOrg(testOrgFile); + expect(logDoneEnabledP({ state, headerIndex: 0 })).toBe(true); + }); + test('Detects "logdone" when set in #+STARTUP with other options', () => { + const testOrgFile = readFixture('schedule_with_logdone_and_other_options'); + const state = parseOrg(testOrgFile); + expect(logDoneEnabledP({ state, headerIndex: 0 })).toBe(true); + }); + test('Does not detect "logdone" when not set', () => { + const testOrgFile = readFixture('schedule'); + const state = parseOrg(testOrgFile); + expect(logDoneEnabledP({ state, headerIndex: 0 })).toBe(false); + }); + test('Detects "logdone" when set via a property list', () => { + const testOrgFile = readFixture('schedule_with_logdone_property'); + const state = parseOrg(testOrgFile); + expect(logDoneEnabledP({ state, headerIndex: 1 })).toBe(true); + expect(logDoneEnabledP({ state, headerIndex: 2 })).toBe(true); + expect(logDoneEnabledP({ state, headerIndex: 5 })).toBe(false); + expect(logDoneEnabledP({ state, headerIndex: 7 })).toBe(true); + }); + }); }); describe('TODO keywords at EOF', () => { diff --git a/src/components/OrgFile/components/AttributedString/components/TablePart/stylesheet.css b/src/components/OrgFile/components/AttributedString/components/TablePart/stylesheet.css index 4c44acb85..8dffe39ba 100644 --- a/src/components/OrgFile/components/AttributedString/components/TablePart/stylesheet.css +++ b/src/components/OrgFile/components/AttributedString/components/TablePart/stylesheet.css @@ -33,4 +33,4 @@ .insert-timestamp-icon { margin-right: 5px; -} \ No newline at end of file +} diff --git a/src/components/OrgFile/components/DescriptionEditorModal/stylesheet.css b/src/components/OrgFile/components/DescriptionEditorModal/stylesheet.css index b4371e50c..b3750d04d 100644 --- a/src/components/OrgFile/components/DescriptionEditorModal/stylesheet.css +++ b/src/components/OrgFile/components/DescriptionEditorModal/stylesheet.css @@ -75,4 +75,4 @@ .all-tags__tag--in-use { background-color: var(--base01); color: var(--base2); -} \ No newline at end of file +} diff --git a/src/components/OrgFile/components/DrawerActionBar/stylesheet.css b/src/components/OrgFile/components/DrawerActionBar/stylesheet.css index 35357c729..b8fe586ba 100644 --- a/src/components/OrgFile/components/DrawerActionBar/stylesheet.css +++ b/src/components/OrgFile/components/DrawerActionBar/stylesheet.css @@ -1,5 +1,5 @@ .static-action-bar { - padding-top: 20px; - padding-left: 20px; - background-color: var(--base3); - } \ No newline at end of file + padding-top: 20px; + padding-left: 20px; + background-color: var(--base3); +} diff --git a/src/components/OrgFile/components/Header/index.js b/src/components/OrgFile/components/Header/index.js index 418afd48d..fb3230a71 100644 --- a/src/components/OrgFile/components/Header/index.js +++ b/src/components/OrgFile/components/Header/index.js @@ -141,7 +141,8 @@ class Header extends PureComponent { if (swipeDistance >= this.SWIPE_ACTION_ACTIVATION_DISTANCE) { this.props.org.advanceTodoState( this.props.header.get('id'), - this.props.shouldLogIntoDrawer + this.props.shouldLogIntoDrawer, + this.props.shouldLogDone ); } @@ -564,6 +565,7 @@ const mapStateToProps = (state, ownProps) => { return { bulletStyle: state.base.get('bulletStyle'), shouldLogIntoDrawer: state.base.get('shouldLogIntoDrawer'), + shouldLogDone: state.base.get('shouldLogDone'), closeSubheadersRecursively: state.base.get('closeSubheadersRecursively'), narrowedHeader, isNarrowed: !!narrowedHeader && narrowedHeader.get('id') === ownProps.header.get('id'), diff --git a/src/components/OrgFile/components/NoteEditorModal/stylesheet.css b/src/components/OrgFile/components/NoteEditorModal/stylesheet.css index cd7768361..30eeade4b 100644 --- a/src/components/OrgFile/components/NoteEditorModal/stylesheet.css +++ b/src/components/OrgFile/components/NoteEditorModal/stylesheet.css @@ -6,4 +6,4 @@ height: 60px; width: 100%; -} \ No newline at end of file +} diff --git a/src/components/OrgFile/components/SearchModal/stylesheet.css b/src/components/OrgFile/components/SearchModal/stylesheet.css index bf8d9d086..969e311cd 100644 --- a/src/components/OrgFile/components/SearchModal/stylesheet.css +++ b/src/components/OrgFile/components/SearchModal/stylesheet.css @@ -3,7 +3,7 @@ /* Currently, the markup is the same as in the AgendaModal component. * Hence the CSS rules from there apply. */ -.search-input-container{ +.search-input-container { display: flex; margin-bottom: 1em; } diff --git a/src/components/OrgFile/components/TableEditorModal/stylesheet.css b/src/components/OrgFile/components/TableEditorModal/stylesheet.css index 920762599..6d1d37f73 100644 --- a/src/components/OrgFile/components/TableEditorModal/stylesheet.css +++ b/src/components/OrgFile/components/TableEditorModal/stylesheet.css @@ -34,4 +34,4 @@ .insert-timestamp-icon { margin-right: 5px; -} \ No newline at end of file +} diff --git a/src/components/OrgFile/components/TaskListModal/components/TaskListView/stylesheet.css b/src/components/OrgFile/components/TaskListModal/components/TaskListView/stylesheet.css index c62c10f7c..6431da328 100644 --- a/src/components/OrgFile/components/TaskListModal/components/TaskListView/stylesheet.css +++ b/src/components/OrgFile/components/TaskListModal/components/TaskListView/stylesheet.css @@ -1,7 +1,6 @@ /* Currently, the markup is the same as in the AgendaDay component. Hence */ /* the CSS rules from there apply. */ - .task-list__header-planning-type { color: var(--base01); min-width: 8em; diff --git a/src/components/OrgFile/components/TimestampEditorModal/components/TimestampEditor/stylesheet.css b/src/components/OrgFile/components/TimestampEditorModal/components/TimestampEditor/stylesheet.css index cce2599bb..3f5bd3559 100644 --- a/src/components/OrgFile/components/TimestampEditorModal/components/TimestampEditor/stylesheet.css +++ b/src/components/OrgFile/components/TimestampEditorModal/components/TimestampEditor/stylesheet.css @@ -68,7 +68,8 @@ align-items: center; } -.timestamp-editor__date-input, .timestamp-editor__time-input { +.timestamp-editor__date-input, +.timestamp-editor__time-input { background-color: var(--magenta); color: var(--base3); diff --git a/src/components/OrgFile/components/TitleLine/index.js b/src/components/OrgFile/components/TitleLine/index.js index cb9f1b668..83e6d2d77 100644 --- a/src/components/OrgFile/components/TitleLine/index.js +++ b/src/components/OrgFile/components/TitleLine/index.js @@ -70,7 +70,13 @@ class TitleLine extends PureComponent { } handleTodoClick(event) { - const { header, shouldTapTodoToAdvance, setShouldLogIntoDrawer, onClick } = this.props; + const { + header, + shouldTapTodoToAdvance, + setShouldLogIntoDrawer, + setShouldLogDone, + onClick, + } = this.props; if (!!onClick) { onClick(); @@ -79,7 +85,7 @@ class TitleLine extends PureComponent { this.props.org.selectHeader(header.get('id')); if (shouldTapTodoToAdvance) { - this.props.org.advanceTodoState(null, setShouldLogIntoDrawer); + this.props.org.advanceTodoState(null, setShouldLogIntoDrawer, setShouldLogDone); } } } @@ -190,6 +196,7 @@ const mapStateToProps = (state, ownProps) => { const file = state.org.present.getIn(['files', path]); return { setShouldLogIntoDrawer: state.base.get('shouldLogIntoDrawer'), + setShouldLogDone: state.base.get('shouldLogDone'), shouldTapTodoToAdvance: state.base.get('shouldTapTodoToAdvance'), closeSubheadersRecursively: state.base.get('closeSubheadersRecursively'), isSelected: file.get('selectedHeaderId') === ownProps.header.get('id'), diff --git a/src/components/OrgFile/index.js b/src/components/OrgFile/index.js index 34d5cf1bc..208d1bf86 100644 --- a/src/components/OrgFile/index.js +++ b/src/components/OrgFile/index.js @@ -173,7 +173,7 @@ class OrgFile extends PureComponent { } handleAdvanceTodoHotKey() { - this.props.org.advanceTodoState(null, this.props.shouldLogIntoDrawer); + this.props.org.advanceTodoState(null, this.props.shouldLogIntoDrawer, this.props.shouldLogDone); } handleEditTitleHotKey() { diff --git a/src/components/Settings/index.js b/src/components/Settings/index.js index ae3afda69..a167d5665 100644 --- a/src/components/Settings/index.js +++ b/src/components/Settings/index.js @@ -24,6 +24,7 @@ const Settings = ({ shouldSyncOnBecomingVisibile, shouldShowTitleInOrgFile, shouldLogIntoDrawer, + shouldLogDone, closeSubheadersRecursively, shouldNotIndentOnExport, editorDescriptionHeightValue, @@ -96,6 +97,7 @@ const Settings = ({ base.setShouldShowTitleInOrgFile(!shouldShowTitleInOrgFile); const handleShouldLogIntoDrawer = () => base.setShouldLogIntoDrawer(!shouldLogIntoDrawer); + const handleShouldLogDone = () => base.setShouldLogDone(!shouldLogDone); const handleCloseSubheadersRecursively = () => base.setCloseSubheadersRecursively(!closeSubheadersRecursively); @@ -190,10 +192,10 @@ const Settings = ({
- Log into LOGBOOK drawer when item repeats + Log into LOGBOOK drawer
- Log TODO state changes (currently only for repeating items) into the LOGBOOK drawer - instead of into the body of the heading (default). See the Orgmode documentation on{' '} + Log TODO state changes into the LOGBOOK drawer instead of into the body of the heading + (default). See the Orgmode documentation on{' '} org-log-into-drawer {' '} @@ -203,6 +205,22 @@ const Settings = ({
+
+
+ Create a closing entry when a TODO is marked DONE +
+ Create a clsoing entry when a TODO is makred DONE that will be added to the the logbook + if logIntoDrawer has also been selected or the the body of the heading (default). See + the Orgmode documentation on{' '} + + org-log-done + {' '} + for more information. +
+
+ +
+
When folding a header, fold all subheaders too @@ -412,6 +430,7 @@ const mapStateToProps = (state) => { shouldSyncOnBecomingVisibile: state.base.get('shouldSyncOnBecomingVisibile'), shouldShowTitleInOrgFile: state.base.get('shouldShowTitleInOrgFile'), shouldLogIntoDrawer: state.base.get('shouldLogIntoDrawer'), + shouldLogDone: state.base.get('shouldLogDone'), closeSubheadersRecursively: state.base.get('closeSubheadersRecursively'), shouldNotIndentOnExport: state.base.get('shouldNotIndentOnExport'), hasUnseenChangelog: state.base.get('hasUnseenChangelog'), diff --git a/src/reducers/base.js b/src/reducers/base.js index f831994fa..979fb8e03 100644 --- a/src/reducers/base.js +++ b/src/reducers/base.js @@ -42,6 +42,10 @@ const setShouldShowTitleInOrgFile = (state, action) => const setShouldLogIntoDrawer = (state, action) => state.set('shouldLogIntoDrawer', action.shouldLogIntoDrawer); +const setShouldLogDone = (state, action) => { + return state.set('shouldLogDone', action.shouldLogDone); +}; + const setCloseSubheadersRecursively = (state, action) => state.set('closeSubheadersRecursively', action.closeSubheadersRecursively); @@ -180,6 +184,8 @@ export default (state = Map(), action) => { return setShouldShowTitleInOrgFile(state, action); case 'SET_SHOULD_LOG_INTO_DRAWER': return setShouldLogIntoDrawer(state, action); + case 'SET_SHOULD_LOG_DONE': + return setShouldLogDone(state, action); case 'SET_CLOSE_SUBHEADERS_RECURSIVELY': return setCloseSubheadersRecursively(state, action); case 'SET_SHOULD_NOT_INDENT_ON_EXPORT': diff --git a/src/reducers/org.js b/src/reducers/org.js index 3dbf579b9..a727b78ac 100644 --- a/src/reducers/org.js +++ b/src/reducers/org.js @@ -192,8 +192,56 @@ const updateCookiesOfParentOfHeaderWithId = (file, headerId) => { return updateCookiesOfHeaderWithId(file, parentHeaderId); }; +const addLogNote = (state, action) => { + const { headerIndex, logText } = action; + return state.updateIn(['headers', headerIndex], (header) => { + const updatedHeader = header.update('logNotes', (logNotes) => + parseRawText(logText + (logNotes.isEmpty() ? '\n' : '')).concat(logNotes) + ); + return updatedHeader.set('planningItems', updatePlanningItemsFromHeader(updatedHeader)); + }); +}; + +const addLogBookEntry = (state, action) => { + const { headerIndex, logBookEntry } = action; + // Prepend this single item to the :LOGBOOK: drawer, same as org-log-into-drawer setting + // https://www.gnu.org/software/emacs/manual/html_node/org/Tracking-TODO-state-changes.html + const newEntry = fromJS({ + id: generateId(), + raw: logBookEntry, + }); + + return state.updateIn(['headers', headerIndex, 'logBookEntries'], (entries) => + entries.unshift(newEntry) + ); +}; + +/** + * Add CLOSED: [timestamp] to the heading body or LOGBOOK drawer. + * @param {*} state + * @param {*} headerIndex Index of header where the state change log item should be added. + * @param {boolean} logIntoDrawer By default false, so add log messages as bullets into the body. If true, add into LOGBOOK drawer. + @param {boolean} logDone By default false, so this will not run if logDone is not set in buffer or settings. If true, will be added to either the headline or logbook of a note, depending on logIntoDrawer settings. + @param {string} timeestamp time for the entry. + */ +const addLogDone = (state, action) => { + const { headerIndex, logIntoDrawer, logDone, timestamp } = action; + if (!logDone && !logDoneEnabledP({ state, headerIndex })) { + return state; + } + + const logTimestamp = getTimestampAsText(timestamp, { isActive: false, withStartTime: true }); + const logBookEntry = `CLOSED: ${logTimestamp}`; + if (!logIntoDrawer) { + const logText = logBookEntry; + return addLogNote(state, { headerIndex, logText }); + } + + return addLogBookEntry(state, { headerIndex, logBookEntry }); +}; + const advanceTodoState = (state, action) => { - const { headerId, logIntoDrawer, timestamp } = action; + const { headerId, logIntoDrawer, logDone, timestamp } = action; const existingHeaderId = headerId || state.get('selectedHeaderId'); if (!existingHeaderId) { return state; @@ -222,11 +270,17 @@ const advanceTodoState = (state, action) => { headerIndex, currentTodoState, logIntoDrawer, + logDone, timestamp, }); state = updateCookiesOfParentOfHeaderWithId(state, existingHeaderId); + const lastStateIndex = currentTodoSet.get('keywords').count() - 1; + if (newStateIndex === lastStateIndex) { + return addLogDone(state, { headerIndex, logIntoDrawer, logDone, timestamp }); + } + return state; }; @@ -603,10 +657,10 @@ const refileSubtree = (state, action) => { // `org-log-note-headings`. const addNoteGeneric = (state, action) => { const { noteText } = action; - const headerId = state.get('selectedHeaderId'); const headers = state.get('headers'); const headerIndex = indexOfHeaderWithId(headers, headerId); + return state.updateIn(['headers', headerIndex], (header) => { const updatedHeader = header.update('logNotes', (logNotes) => parseRawText(noteText + (logNotes.isEmpty() ? '\n' : '')).concat(logNotes) @@ -1966,6 +2020,7 @@ function updateHeadlines({ headerIndex, currentTodoState, logIntoDrawer, + logDone, timestamp, }) { if ( @@ -1980,6 +2035,7 @@ function updateHeadlines({ newTodoState, currentTodoState, logIntoDrawer, + logDone, timestamp, }); // Update simple headline (without repeaters) @@ -2001,6 +2057,7 @@ function addTodoStateChangeLogItem( newTodoState, currentTodoState, logIntoDrawer, + logDone, timestamp ) { // This is how the TODO state change will be logged @@ -2031,6 +2088,7 @@ function updatePlanningItemsWithRepeaters({ newTodoState, currentTodoState, logIntoDrawer, + logDone, timestamp, }) { indexedPlanningItemsWithRepeaters.forEach(([planningItem, planningItemIndex]) => { @@ -2126,6 +2184,7 @@ function updatePlanningItemsWithRepeaters({ newTodoState, currentTodoState, logIntoDrawer, + logDone, timestamp ); } @@ -2151,6 +2210,30 @@ export function noLogRepeatEnabledP({ state, headerIndex }) { ); } +/** + * Is the `logdone` enabled for this buffer? + * More info: + * https://www.gnu.org/software/emacs/manual/html_node/org/Closing-items.html + */ + +export function logDoneEnabledP({ state, headerIndex }) { + const logDoneRegex = new RegExp(/.*\blogdone\b.*/); + const fileConfigLines = state.get('fileConfigLines'); + const startupOptLogDone = fileConfigLines.some((element) => { + return element.startsWith('#+STARTUP:') && element.match(logDoneRegex); + }); + if (startupOptLogDone) { + return true; + } + const loggingProp = inheritedValueOfProperty(state.get('headers'), headerIndex, 'LOGGING'); + if (loggingProp) { + return loggingProp.some( + (v) => v.get('type') === 'text' && v.get('contents').match(logDoneRegex) + ); + } + return false; +} + /** * Function wrapper around `updateCookiesOfHeaderWithId` and * `updateCookiesOfParentOfHeaderWithId`. diff --git a/src/reducers/org.unit.test.js b/src/reducers/org.unit.test.js index bdeb2f122..b639d8ff3 100644 --- a/src/reducers/org.unit.test.js +++ b/src/reducers/org.unit.test.js @@ -9,7 +9,7 @@ import rootReducer from './index'; import * as types from '../actions/org'; import { parseOrg } from '../lib/parse_org'; import { headerWithId, headerWithPath, indexOfHeaderWithId } from '../lib/org_utils'; -import { dateForTimestamp, timestampForDate } from '../lib/timestamps'; +import { dateForTimestamp, timestampForDate, getTimestampAsText } from '../lib/timestamps'; import { readInitialState } from '../util/settings_persister'; import { createStore, applyMiddleware } from 'redux'; @@ -827,6 +827,9 @@ describe('org reducer', () => { let state; const testOrgFile = readFixture('various_todos'); const path = 'testfile'; + const date = new Date(); + const inactiveTimestamp = getTimestampAsText(date, { isActive: false, withStartTime: true }); + const newStateChangeLogText = `CLOSED: ${inactiveTimestamp}`; beforeEach(() => { state = setUpStateForFile(path, testOrgFile); @@ -996,6 +999,96 @@ describe('org reducer', () => { check_is_undoable(state, types.advanceTodoState(todoHeaderId, true)); check_is_undoable(state, types.advanceTodoState(doneHeaderId, false)); }); + + it('should advance TODO state and a closing note to the logbook', () => { + const oldHeaders = state.org.present.getIn(['files', path, 'headers']); + + const newState = reducer(state.org.present, types.advanceTodoState(todoHeaderId, true, true)); + const newHeaders = newState.getIn(['files', path, 'headers']); + + check_header_kept(oldHeaders, newHeaders, regularHeaderId); + check_todo_keyword_changed(oldHeaders, newHeaders, todoHeaderId); + check_header_kept(oldHeaders, newHeaders, doneHeaderId); + // The nesting levels remain intact. + expect(extractTitlesAndNestings(oldHeaders)).toEqual(extractTitlesAndNestings(newHeaders)); + + expect( + headerWithId(newState.getIn(['files', path, 'headers']), todoHeaderId).getIn([ + 'logBookEntries', + 0, + 'raw', + ]) + ).toEqual(newStateChangeLogText); + }); + + it('should advance TODO state and add a closing note to the description', () => { + const oldHeaders = state.org.present.getIn(['files', path, 'headers']); + + const newState = reducer( + state.org.present, + types.advanceTodoState(todoHeaderId, false, true) + ); + const newHeaders = newState.getIn(['files', path, 'headers']); + check_header_kept(oldHeaders, newHeaders, regularHeaderId); + check_todo_keyword_changed(oldHeaders, newHeaders, todoHeaderId); + check_header_kept(oldHeaders, newHeaders, doneHeaderId); + // The nesting levels remain intact. + expect(extractTitlesAndNestings(oldHeaders)).toEqual(extractTitlesAndNestings(newHeaders)); + + expect( + headerWithId(newState.getIn(['files', path, 'headers']), todoHeaderId).getIn([ + 'logNotes', + 0, + 'contents', + ]) + ).toEqual('CLOSED: '); + + const { day, month, startHour, startMinute, year } = headerWithId( + newState.getIn(['files', path, 'headers']), + todoHeaderId + ) + .getIn(['logNotes', 1, 'firstTimestamp']) + .toJS(); + + expect(timestampForDate(new Date(year, month - 1, day, startHour, startMinute))).toEqual( + timestampForDate(date) + ); + }); + + it('no note should be added to the logbook when note is advanced from DONE state', () => { + const oldHeaders = state.org.present.getIn(['files', path, 'headers']); + + const newState = reducer(state.org.present, types.advanceTodoState(todoHeaderId, true, true)); + const newHeaders = newState.getIn(['files', path, 'headers']); + check_header_kept(oldHeaders, newHeaders, regularHeaderId); + check_todo_keyword_changed(oldHeaders, newHeaders, todoHeaderId); + check_header_kept(oldHeaders, newHeaders, doneHeaderId); + // The nesting levels remain intact. + expect(extractTitlesAndNestings(oldHeaders)).toEqual(extractTitlesAndNestings(newHeaders)); + + expect(headerWithId(newHeaders, repeatingHeaderId).get('logBookEntries').size).toEqual( + headerWithId(oldHeaders, repeatingHeaderId).get('logBookEntries').size + ); + }); + + it('no note should be added to the description when note is advanced from DONE state', () => { + const oldHeaders = state.org.present.getIn(['files', path, 'headers']); + + const newState = reducer( + state.org.present, + types.advanceTodoState(todoHeaderId, false, true) + ); + const newHeaders = newState.getIn(['files', path, 'headers']); + check_header_kept(oldHeaders, newHeaders, regularHeaderId); + check_todo_keyword_changed(oldHeaders, newHeaders, todoHeaderId); + check_header_kept(oldHeaders, newHeaders, doneHeaderId); + // The nesting levels remain intact. + expect(extractTitlesAndNestings(oldHeaders)).toEqual(extractTitlesAndNestings(newHeaders)); + + expect(headerWithId(newHeaders, repeatingHeaderId).get('logNotes').size).toEqual( + headerWithId(oldHeaders, repeatingHeaderId).get('logNotes').size + ); + }); }); describe('UPDATE_LOG_ENTRY_TIME', () => { diff --git a/src/util/settings_persister.js b/src/util/settings_persister.js index 277eb3ebd..7200bfc2d 100644 --- a/src/util/settings_persister.js +++ b/src/util/settings_persister.js @@ -156,6 +156,11 @@ export const persistableFields = [ name: 'shouldLogIntoDrawer', type: 'boolean', }, + { + category: 'base', + name: 'shouldLogDone', + type: 'boolean', + }, { category: 'base', name: 'closeSubheadersRecursively', diff --git a/test_helpers/fixtures/schedule_with_logdone.org b/test_helpers/fixtures/schedule_with_logdone.org new file mode 100644 index 000000000..576eaedc6 --- /dev/null +++ b/test_helpers/fixtures/schedule_with_logdone.org @@ -0,0 +1,3 @@ +#+STARTUP: logdone + +* TODO Header diff --git a/test_helpers/fixtures/schedule_with_logdone_and_other_options.org b/test_helpers/fixtures/schedule_with_logdone_and_other_options.org new file mode 100644 index 000000000..03f4440d9 --- /dev/null +++ b/test_helpers/fixtures/schedule_with_logdone_and_other_options.org @@ -0,0 +1,4 @@ +#+STARTUP: showeverything logdone + +* TODO Header + SCHEDULED: <2024-11-27 Wed +1d> diff --git a/test_helpers/fixtures/schedule_with_logdone_property.org b/test_helpers/fixtures/schedule_with_logdone_property.org new file mode 100644 index 000000000..bf49b2bc0 --- /dev/null +++ b/test_helpers/fixtures/schedule_with_logdone_property.org @@ -0,0 +1,18 @@ +* Header + :PROPERTIES: + :LOGGING: logdone + :END: +** Intermediate +*** TODO Leaf +- Posterity +* Header Two +** Middle + :PROPERTIES: + :LOGGING: nologdone + :END: +*** Beep +** Middle 2 + :PROPERTIES: + :LOGGING: logdone + :END: +*** Boop