├── .github ├── media │ ├── demo.gif │ └── logo.png └── workflows │ └── push.yml ├── .gitignore ├── Makefile ├── Makefile.ios ├── README.md ├── Remember.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ ├── remember-ios.xcscheme │ └── remember.xcscheme ├── bin ├── download-macos-artifacts ├── pbracket ├── pbraco ├── remove-dylibs └── sign-dylibs ├── core ├── .gitignore ├── appdata.rkt ├── command.rkt ├── database.rkt ├── dylib.entitlements ├── entry.rkt ├── event.rkt ├── info.rkt ├── main.rkt ├── ring.rkt ├── schema.rkt ├── tag.rkt ├── timezone.rkt └── undo.rkt ├── manual ├── index.scrbl ├── info.rkt └── shortcut.rkt ├── migrations ├── 0001-create-entries-table.sql ├── 0002-create-entries-status-due-at-index.sql ├── 0003-create-tags-table.sql ├── 0004-create-entry-tags-table.sql ├── 0005-add-recurrence-columns-to-entries.sql ├── 0006-add-entry-updated-at-column.sql └── 0007-add-entry-tags-unique-index.sql ├── remember-ios ├── AddReminderIntent.swift ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ └── icon.png │ └── Contents.json ├── Backend.swift ├── BackendExtensions.swift ├── CommandField.swift ├── CommandView.swift ├── ContentView.swift ├── DeviceShakeViewModifier.swift ├── FolderSyncer.swift ├── Info.plist ├── NotificationsManager.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── PrivacyInfo.xcprivacy ├── Remember.swift ├── ReminderDetailView.swift ├── RemindersView.swift ├── SettingsSyncView.swift ├── SettingsView.swift └── Store.swift ├── remember ├── AppDelegate.swift ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── 1024x1024@3x-1024.png │ │ ├── 1024x1024@3x-128.png │ │ ├── 1024x1024@3x-16.png │ │ ├── 1024x1024@3x-256.png │ │ ├── 1024x1024@3x-32.png │ │ ├── 1024x1024@3x-512.png │ │ ├── 1024x1024@3x-64.png │ │ └── Contents.json │ ├── Contents.json │ ├── Icon.imageset │ │ ├── 32x32.png │ │ ├── 32x32@2x.png │ │ ├── 32x32@3x.png │ │ └── Contents.json │ ├── OnboardingStep1-1.imageset │ │ ├── Contents.json │ │ └── OnboardingStep1-1.png │ ├── OnboardingStep1-2.imageset │ │ ├── Contents.json │ │ └── OnboardingStep1-2.png │ ├── OnboardingStep1-3.imageset │ │ ├── Contents.json │ │ └── OnboardingStep1-3.png │ ├── OnboardingStep2-1.imageset │ │ ├── Contents.json │ │ └── OnboardingStep2-1.png │ ├── OnboardingStep2-2.imageset │ │ ├── Contents.json │ │ └── OnboardingStep2-2.png │ └── StatusBarIcon.imageset │ │ ├── 16x16.png │ │ ├── 32x32.png │ │ ├── 64x64.png │ │ └── Contents.json ├── Backend.swift ├── BackendExtensions.swift ├── Base.lproj │ └── Main.storyboard ├── BridgingHeader.h ├── CommandField.swift ├── ContentView.swift ├── EntryList.swift ├── EntryListItem.swift ├── FolderSync.swift ├── Info.plist ├── KeyboardShortcut.swift ├── KeyboardShortcutDefaults.swift ├── KeyboardShortcutField.swift ├── Keycode.swift ├── Notifications.swift ├── Onboarding.swift ├── Preferences.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Snooze.swift ├── StatusItem.swift ├── Store.swift ├── UserNotifications.swift ├── VisualEffectBackground.swift ├── remember-core.entitlements ├── remember.entitlements └── vendor │ └── DDHotKey │ ├── DDHotKeyCenter.h │ ├── DDHotKeyCenter.m │ ├── DDHotKeyTextField.h │ ├── DDHotKeyTextField.m │ ├── DDHotKeyUtilities.h │ ├── DDHotKeyUtilities.m │ └── README.markdown ├── tests ├── info.rkt └── remember │ ├── command.rkt │ ├── entry.rkt │ ├── ring.rkt │ └── undo.rkt └── website ├── .gitattributes ├── Makefile ├── assets ├── .gitattributes ├── Makefile ├── app-store.svg ├── demo-adding.gif ├── demo-adding.mp4 ├── demo-listing.gif ├── demo-listing.mp4 ├── demo-notify.gif ├── demo-notify.mp4 ├── demo.gif ├── demo.mp4 └── logo.png ├── index.html └── manual ├── index.html ├── manual-fonts.css ├── manual-racket.css ├── manual-racket.js ├── manual-style.css ├── racket.css ├── scribble-common.js └── scribble.css /.github/media/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bogdanp/remember/e5749f58776ad7f00a0ecfe5f4db8a5c6d9b5ffd/.github/media/demo.gif -------------------------------------------------------------------------------- /.github/media/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bogdanp/remember/e5749f58776ad7f00a0ecfe5f4db8a5c6d9b5ffd/.github/media/logo.png -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | on: push 2 | name: CI 3 | jobs: 4 | build_core: 5 | runs-on: ${{ matrix.environment }} 6 | name: Build core (${{ matrix.platform }}) on ${{ matrix.environment }} 7 | strategy: 8 | matrix: 9 | environment: [macos-13, macos-14] 10 | include: 11 | - environment: macos-13 12 | platform: x86_64 13 | arch: x64 14 | - environment: macos-14 15 | platform: arm64 16 | arch: arm64 17 | steps: 18 | - uses: actions/checkout@master 19 | - uses: Bogdanp/setup-racket@v1.11 20 | with: 21 | architecture: ${{ matrix.arch }} 22 | distribution: 'full' 23 | variant: 'CS' 24 | version: '8.13' 25 | packages: http-easy-lib 26 | - name: Clone Noise 27 | run: | 28 | mkdir ../../sandbox 29 | env GIT_LFS_SKIP_SMUDGE=1 \ 30 | git clone --depth 1 --branch racket-8.13 https://github.com/Bogdanp/Noise ../../sandbox/Noise 31 | raco pkg install -D --batch --auto ../../sandbox/noise/Racket/noise-serde-lib/ 32 | - name: Install core 33 | run: | 34 | raco pkg install -D --batch --auto --name remember core/ 35 | raco pkg install -D --batch --auto --name remember-test tests/ 36 | - name: Run tests 37 | run: raco test tests/ 38 | - name: Build manual 39 | run: make remember/res/manual/index.html 40 | - name: Build core 41 | run: make 42 | - name: Show your work 43 | run: find remember/res 44 | - name: Upload manual 45 | uses: actions/upload-artifact@v4 46 | with: 47 | name: manual-${{ matrix.platform }} 48 | path: remember/res/manual/ 49 | - name: Upload core 50 | uses: actions/upload-artifact@v4 51 | with: 52 | name: core-${{ matrix.platform }} 53 | path: | 54 | remember/res/core-${{ matrix.platform }}.zo 55 | remember/res/runtime-${{ matrix.platform }}/ 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | compiled/ 3 | res/ 4 | temp/ 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ARCH=$(shell uname -m) 2 | 3 | APP_SRC=remember 4 | RKT_SRC=core 5 | RKT_FILES=$(shell find ${RKT_SRC} -name '*.rkt') 6 | RKT_MAIN_ZO=${RKT_SRC}/compiled/main_rkt.zo 7 | 8 | RESOURCES_PATH=${APP_SRC}/res 9 | RUNTIME_NAME=runtime-${ARCH} 10 | RUNTIME_PATH=${RESOURCES_PATH}/${RUNTIME_NAME} 11 | MANUAL_PATH=${RESOURCES_PATH}/manual 12 | 13 | CORE_ZO=${RESOURCES_PATH}/core-${ARCH}.zo 14 | 15 | .PHONY: all 16 | all: ${CORE_ZO} ${APP_SRC}/Backend.swift 17 | 18 | .PHONY: clean 19 | clean: 20 | find core -type d -name compiled | xargs rm -fr 21 | rm -fr ${RESOURCES_PATH} 22 | 23 | ${RKT_MAIN_ZO}: ${RKT_FILES} 24 | raco make -j 16 -v ${RKT_SRC}/main.rkt 25 | 26 | ${CORE_ZO}: ${RKT_MAIN_ZO} 27 | mkdir -p ${RESOURCES_PATH} 28 | rm -fr ${RUNTIME_PATH} 29 | raco ctool \ 30 | --runtime ${RUNTIME_PATH} \ 31 | --runtime-access ${RUNTIME_NAME} \ 32 | --mods $@ ${RKT_SRC}/main.rkt 33 | 34 | ${APP_SRC}/Backend.swift: ${CORE_ZO} 35 | raco noise-serde-codegen ${RKT_SRC}/main.rkt > $@ 36 | 37 | ${MANUAL_PATH}/index.html: manual/*.scrbl 38 | raco scribble --html --dest ${MANUAL_PATH} +m manual/index.scrbl 39 | 40 | website/manual/index.html: manual/*.scrbl 41 | make -C website manual/index.html 42 | -------------------------------------------------------------------------------- /Makefile.ios: -------------------------------------------------------------------------------- 1 | APP_SRC=remember-ios 2 | RKT_SRC=core 3 | RKT_FILES=$(shell find ${RKT_SRC} -name '*.rkt') 4 | RKT_MAIN_ZO=${RKT_SRC}/compiled/main_rkt.zo 5 | 6 | RESOURCES_PATH=${APP_SRC}/res 7 | RUNTIME_NAME=runtime 8 | RUNTIME_PATH=${RESOURCES_PATH}/${RUNTIME_NAME} 9 | MANUAL_PATH=${RESOURCES_PATH}/manual 10 | 11 | CORE_ZO=${RESOURCES_PATH}/core.zo 12 | 13 | .PHONY: all 14 | all: ${CORE_ZO} ${APP_SRC}/Backend.swift 15 | 16 | .PHONY: clean 17 | clean: 18 | rm -fr ${RESOURCES_PATH} 19 | 20 | ${RKT_MAIN_ZO}: ${RKT_FILES} 21 | ./bin/pbraco make -j 16 -v ${RKT_SRC}/main.rkt 22 | 23 | ${CORE_ZO}: ${RKT_MAIN_ZO} 24 | mkdir -p ${RESOURCES_PATH} 25 | rm -fr ${RUNTIME_PATH} 26 | ./bin/pbraco ctool \ 27 | --runtime ${RUNTIME_PATH} \ 28 | --runtime-access ${RUNTIME_NAME} \ 29 | --mods $@ ${RKT_SRC}/main.rkt 30 | 31 | ${APP_SRC}/Backend.swift: ${CORE_ZO} 32 | ./bin/pbraco noise-serde-codegen ${RKT_SRC}/main.rkt > $@ 33 | 34 | ${MANUAL_PATH}/index.html: manual/*.scrbl 35 | ./bin/pbraco scribble --html --dest ${MANUAL_PATH} +m manual/index.scrbl 36 | 37 | website/manual/index.html: manual/*.scrbl 38 | make -C website manual/index.html 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Remember Logo 4 | 5 |

6 | Remember 7 | 8 | GitHub Actions status 9 | 10 |

11 |

12 | 13 | Remember is a tool for stashing distractions away for later. You bind 14 | it to a hotkey (⌥⎵ by default) and whenever something unexpected pops 15 | up -- say you suddenly realize you need to stock up on milk -- you hit 16 | the hotkey, then type in `buy milk +1h` and hit return. An hour 17 | later, you'll get reminded that you need to go out and buy some milk. 18 | 19 |

20 | Demo 21 |

22 | 23 | If you find Remember useful, please consider [buying a copy]. 24 | 25 | This application is **not** Open Source. I'm providing the source 26 | code here because I want users to be able to see the code they're 27 | running and even change and build it for themselves if they want to. 28 | In that vein, you're free to read, build and run the application 29 | yourself, on your own devices, but please don't share any built 30 | artifacts with others. 31 | 32 | [buying a copy]: https://remember.defn.io 33 | 34 | ## Build 35 | 36 | ### Requirements 37 | 38 | * [Racket 8.12 CS](https://racket-lang.org/) 39 | * [Noise](https://github.com/Bogdanp/Noise) 40 | * macOS Catalina 41 | * Xcode 12+ 42 | 43 | ### First-time Setup 44 | 45 | $ raco pkg install --name remember core/ 46 | 47 | ### Building 48 | 49 | $ make 50 | $ xcodebuild 51 | 52 | ## License 53 | 54 | Copyright 2019-2024 CLEARTYPE SRL. All rights reserved. 55 | -------------------------------------------------------------------------------- /Remember.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Remember.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Remember.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "6ebdba50b24c762f95398173d1bc6fa5c6be5062592c3f0f3ccb421f747fbcd8", 3 | "pins" : [ 4 | { 5 | "identity" : "launchatlogin", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/sindresorhus/LaunchAtLogin", 8 | "state" : { 9 | "revision" : "e8171b3e38a2816f579f58f3dac1522aa39efe41", 10 | "version" : "4.2.0" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /Remember.xcodeproj/xcshareddata/xcschemes/remember-ios.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 58 | 59 | 60 | 61 | 67 | 69 | 75 | 76 | 77 | 78 | 80 | 81 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /Remember.xcodeproj/xcshareddata/xcschemes/remember.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 43 | 49 | 50 | 51 | 52 | 53 | 63 | 65 | 71 | 72 | 73 | 74 | 78 | 79 | 80 | 81 | 87 | 89 | 95 | 96 | 97 | 98 | 100 | 101 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /bin/download-macos-artifacts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env racket 2 | #lang racket/base 3 | 4 | (require file/unzip 5 | (prefix-in http: net/http-easy) 6 | racket/file 7 | racket/path 8 | threading) 9 | 10 | (define current-token 11 | (make-parameter (getenv "GH_TOKEN"))) 12 | (define here 13 | (path-only (syntax-source #'here))) 14 | (define res-path 15 | (normalize-path (build-path here 'up "remember" "res"))) 16 | 17 | (define get 18 | (make-keyword-procedure 19 | (lambda (kws kw-args path . args) 20 | (keyword-apply 21 | http:get 22 | kws kw-args 23 | #:auth (http:bearer-auth (current-token)) 24 | (if (not (regexp-match? #rx"^https://" path)) 25 | (format "https://api.github.com/~a" path) 26 | path) 27 | args)))) 28 | 29 | (define (get-artifacts-by-name run-id) 30 | (define data 31 | (http:response-json 32 | (get (format "repos/Bogdanp/remember/actions/runs/~a/artifacts" run-id)))) 33 | (for/hash ([elt-data (in-list (hash-ref data 'artifacts))]) 34 | (values (hash-ref elt-data 'name) elt-data))) 35 | 36 | (define (download-archive uri dst-path) 37 | (define res #f) 38 | (dynamic-wind 39 | (lambda () 40 | (set! res (get #:stream? #t uri))) 41 | (lambda () 42 | (call-with-unzip 43 | (http:response-output res) 44 | (lambda (src-path) 45 | (for ([filename (in-list (directory-list src-path))]) 46 | (copy-directory/files 47 | (build-path src-path filename) 48 | (build-path dst-path filename)))))) 49 | (lambda () 50 | (http:response-close! res)))) 51 | 52 | (module+ main 53 | (require racket/cmdline) 54 | (define run-id 55 | (command-line 56 | #:args [run-id] 57 | run-id)) 58 | (unless (current-token) 59 | (eprintf "error: GH_TOKEN environment variable not set~n") 60 | (exit 1)) 61 | (define artifacts-by-name 62 | (get-artifacts-by-name run-id)) 63 | (delete-directory/files #:must-exist? #f res-path) 64 | (make-directory* (build-path res-path "manual")) 65 | (for ([(name dst-path) (in-hash 66 | (hash 67 | "core-arm64" res-path 68 | "core-x86_64" res-path 69 | "manual-arm64" (build-path res-path "manual")))]) 70 | (define uri 71 | (~> (hash-ref artifacts-by-name name) 72 | (hash-ref 'archive_download_url))) 73 | (eprintf "Downloading ~a...~n" name) 74 | (download-archive uri dst-path))) 75 | -------------------------------------------------------------------------------- /bin/pbracket: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Helper to run Racket in cross-compilation mode with a portable 4 | # bytecode target. Expects the PBRACKET_ROOT environment variable to 5 | # be set before running. 6 | 7 | set -euo pipefail 8 | 9 | RACKET_DIR="${PBRACKET_ROOT}/racket" 10 | BUILD_DIR="${RACKET_DIR}/src/build/cs/c" 11 | COMPILED_DIR="${BUILD_DIR}/compiled" 12 | 13 | racket \ 14 | --cross-compiler tpb64l "$BUILD_DIR" \ 15 | -MCR "$COMPILED_DIR": \ 16 | -G "${RACKET_DIR}/etc" \ 17 | -X "${RACKET_DIR}/collects" \ 18 | "$@" 19 | -------------------------------------------------------------------------------- /bin/pbraco: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | HERE="$(dirname "$0")" 6 | "$HERE/pbracket" -N raco -U -l- raco "$@" 7 | -------------------------------------------------------------------------------- /bin/remove-dylibs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | find "${PROJECT_DIR}/remember-ios/res" -name "*.dylib" -exec rm \{\} \; 6 | -------------------------------------------------------------------------------- /bin/sign-dylibs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # On macOS, add this script as an early build phase to sign 4 | # distributed dylibs. Make sure to disable "User Script Sandboxing" 5 | # under "Build Settings" > "Build Options" in the Xcode target 6 | # configuration. 7 | 8 | set -euo pipefail 9 | 10 | while read -r path; do 11 | filename=$(basename "$path") 12 | libname=$(echo "$filename" | cut -d. -f1) 13 | codesign --remove-signature "$path" 14 | codesign \ 15 | --timestamp \ 16 | --entitlements="${PROJECT_DIR}/core/dylib.entitlements" \ 17 | --sign "${EXPANDED_CODE_SIGN_IDENTITY}" \ 18 | -i "${PRODUCT_BUNDLE_IDENTIFIER}.${libname}" \ 19 | -o runtime \ 20 | "$path" 21 | done < <(find "${PROJECT_DIR}/remember/res" -name '*.dylib') 22 | -------------------------------------------------------------------------------- /core/.gitignore: -------------------------------------------------------------------------------- 1 | compiled 2 | -------------------------------------------------------------------------------- /core/appdata.rkt: -------------------------------------------------------------------------------- 1 | #lang racket/base 2 | 3 | (require racket/file) 4 | 5 | (provide 6 | current-application-data-directory 7 | build-application-path) 8 | 9 | (define app-id "io.defn.remember") 10 | 11 | (define (current-application-data-directory) 12 | (case (system-type 'os) 13 | [(macosx) 14 | (build-path 15 | (find-system-path 'home-dir) 16 | "Library" 17 | "Application Support" 18 | app-id)] 19 | 20 | [else 21 | (error 'current-application-data-directory "not implemented")])) 22 | 23 | (define (build-application-path . args) 24 | (apply build-path (current-application-data-directory) args)) 25 | 26 | (make-directory* (current-application-data-directory)) 27 | -------------------------------------------------------------------------------- /core/database.rkt: -------------------------------------------------------------------------------- 1 | #lang racket/base 2 | 3 | (require db 4 | gregor 5 | racket/contract/base 6 | racket/file 7 | racket/format 8 | racket/list 9 | racket/path 10 | "appdata.rkt") 11 | 12 | (provide 13 | id/c 14 | isolation-level/c 15 | 16 | (contract-out 17 | [make-db (-> (-> connection?) db?)] 18 | [current-db (parameter/c db?)] 19 | [call-with-database-connection 20 | (->* [(-> connection? any)] 21 | [#:db db?] 22 | any)] 23 | [call-with-database-transaction 24 | (->* [(-> connection? any)] 25 | [#:db db? 26 | #:isolation isolation-level/c] 27 | any)]) 28 | sql-> 29 | backup-database! 30 | create-database-copy! 31 | merge-database-copy!) 32 | 33 | (define id/c 34 | exact-nonnegative-integer?) 35 | 36 | (define isolation-level/c 37 | (or/c #f 38 | 'serializable 39 | 'repeatable-read 40 | 'read-committed 41 | 'read-uncommitted)) 42 | 43 | (struct db (conn sem) 44 | #:transparent) 45 | 46 | (define (make-db connector) 47 | (db (connector) 48 | (make-semaphore 1))) 49 | 50 | (define current-db 51 | (make-parameter 52 | (make-db 53 | (lambda () 54 | (sqlite3-connect 55 | #:mode 'create 56 | #:database (build-application-path "remember.sqlite3") 57 | #:use-place 'os-thread))))) 58 | 59 | (define current-connection 60 | (make-parameter #f)) 61 | 62 | (define (call-with-database-connection proc #:db [the-db (current-db)]) 63 | (call-with-semaphore (db-sem the-db) 64 | (λ () (proc (db-conn the-db))))) 65 | 66 | (define (call-with-database-transaction proc 67 | #:db [the-db (current-db)] 68 | #:isolation [isolation #f]) 69 | (cond 70 | [(current-connection) 71 | => (lambda (conn) 72 | (call-with-transaction conn 73 | (lambda () 74 | (proc conn))))] 75 | 76 | [else 77 | (call-with-database-connection 78 | #:db the-db 79 | (lambda (conn) 80 | (parameterize ([current-connection conn]) 81 | (call-with-transaction conn 82 | #:isolation isolation 83 | (lambda () 84 | (proc conn))))))])) 85 | 86 | (define (sql-> v) 87 | (cond 88 | [(sql-null? v) #f] 89 | [else v])) 90 | 91 | (define max-backups 7) 92 | 93 | (define (delete-old-backups!) 94 | (define all-backups 95 | (sort 96 | (find-files 97 | (lambda (p) 98 | (equal? (path-get-extension p) #".bak")) 99 | (build-application-path)) 100 | (lambda (a b) 101 | (bytesbytes a) 102 | (path->bytes b))))) 103 | 104 | (when (> (length all-backups) max-backups) 105 | (for-each delete-file (drop-right all-backups max-backups)))) 106 | 107 | (define (backup-database!) 108 | (define database-path (build-application-path "remember.sqlite3")) 109 | (when (file-exists? database-path) 110 | (define backup-suffix (~a "-" (~t (today) "yyyy-MM-dd") ".bak")) 111 | (define backup-path (path-add-extension database-path (string->bytes/utf-8 backup-suffix))) 112 | (copy-file database-path backup-path #t) 113 | (delete-old-backups!))) 114 | 115 | (define (create-database-copy!) 116 | (define path (path->string (make-temporary-file))) 117 | (begin0 path 118 | (delete-file path) 119 | (call-with-database-connection 120 | (lambda (conn) 121 | (query-exec conn "vacuum into ?" path))))) 122 | 123 | (define (merge-database-copy! path) 124 | (call-with-database-connection 125 | (lambda (conn) 126 | (dynamic-wind 127 | (lambda () 128 | (query-exec conn "attach ? as newer_db" path)) 129 | (lambda () 130 | (call-with-transaction conn 131 | (lambda () 132 | (query-exec conn #< DATETIME(updated_at) 149 | QUERY 150 | ) 151 | 152 | (query-exec conn #< 2 | 3 | 4 | 5 | 6 | com.apple.security.app-sandbox 7 | 8 | com.apple.security.inherit 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /core/event.rkt: -------------------------------------------------------------------------------- 1 | #lang racket/base 2 | 3 | (require noise/backend 4 | noise/serde) 5 | 6 | (provide 7 | entries-did-change) 8 | 9 | (define-callout (entries-did-change-cb [ok : Bool])) 10 | 11 | (define ready-for-changes? #f) 12 | 13 | (define-rpc (mark-ready-for-changes) 14 | (set! ready-for-changes? #t)) 15 | 16 | (define (entries-did-change) 17 | (when ready-for-changes? 18 | (entries-did-change-cb #t))) 19 | -------------------------------------------------------------------------------- /core/info.rkt: -------------------------------------------------------------------------------- 1 | #lang info 2 | 3 | (define version "2024.04.01") 4 | (define collection "remember") 5 | (define deps '("base" 6 | "db-lib" 7 | "deta-lib" 8 | "gregor-lib" 9 | ["noise-serde-lib" #:version "0.7"] 10 | "threading-lib")) 11 | -------------------------------------------------------------------------------- /core/main.rkt: -------------------------------------------------------------------------------- 1 | #lang racket/base 2 | 3 | (require gregor 4 | noise/backend 5 | noise/serde 6 | "command.rkt" 7 | "database.rkt" 8 | "entry.rkt" 9 | "schema.rkt" 10 | "timezone.rkt" 11 | "undo.rkt") 12 | 13 | (provide 14 | main) 15 | 16 | (define-rpc (ping : String) 17 | "pong") 18 | 19 | (define-rpc (parse [command s : String] : (Listof Token)) 20 | (parse-command s)) 21 | 22 | (define-rpc (commit [command s : String] : Entry) 23 | (entry->Entry (commit! s))) 24 | 25 | (define-rpc (update [entry-with-id id : UVarint] 26 | [and-command s : String] : (Optional Entry)) 27 | (define e (update! id s)) 28 | (and e (entry->Entry e))) 29 | 30 | (define-rpc (archive [entry-with-id id : UVarint]) 31 | (void (archive-entry! id))) 32 | 33 | (define-rpc (snooze [entry-with-id id : UVarint] 34 | [for-minutes minutes : UVarint]) 35 | (void (snooze-entry! id minutes))) 36 | 37 | (define-rpc (delete [entry-with-id id : UVarint]) 38 | (void (delete-entry! id))) 39 | 40 | (define-rpc (get-pending-entries : (Listof Entry)) 41 | (map entry->Entry (find-pending-entries))) 42 | 43 | (define-rpc (get-due-entries : (Listof Entry)) 44 | (map entry->Entry (find-due-entries))) 45 | 46 | (define-rpc (undo) 47 | (void (undo!))) 48 | 49 | (define-rpc (create-database-copy : String) 50 | (create-database-copy!)) 51 | 52 | (define-rpc (merge-database-copy [at-path path : String]) 53 | (merge-database-copy! path)) 54 | 55 | (define-callout (entries-due-cb [entries : (Listof Entry)])) 56 | 57 | (define-rpc (start-scheduler) 58 | (void (do-start-scheduler))) 59 | 60 | (define scheduler-custodian 61 | (make-custodian)) 62 | 63 | (define (do-start-scheduler) 64 | (parameterize ([current-custodian scheduler-custodian]) 65 | (thread 66 | (lambda () 67 | (let loop () 68 | (define deadline (+ (current-inexact-milliseconds) 30000)) 69 | (define due-entries (get-due-entries)) 70 | (unless (null? due-entries) 71 | (entries-due-cb due-entries)) 72 | (sync (alarm-evt deadline)) 73 | (loop)))))) 74 | 75 | (define (main in-fd out-fd) 76 | (current-timezone (get-current-system-timezone)) 77 | (module-cache-clear!) 78 | (backup-database!) 79 | (migrate!) 80 | (let/cc trap 81 | (parameterize ([exit-handler 82 | (lambda (err-or-code) 83 | (when (exn:fail? err-or-code) 84 | ((error-display-handler) 85 | (format "trap: ~a" (exn-message err-or-code)) 86 | err-or-code)) 87 | (trap))]) 88 | (define stop 89 | (serve in-fd out-fd)) 90 | (with-handlers ([exn:break? void]) 91 | (sync never-evt)) 92 | (stop)))) 93 | -------------------------------------------------------------------------------- /core/ring.rkt: -------------------------------------------------------------------------------- 1 | #lang racket/base 2 | 3 | (require racket/contract/base) 4 | 5 | (provide 6 | (contract-out 7 | [make-ring (-> exact-positive-integer? ring?)] 8 | [ring? (-> any/c boolean?)] 9 | [ring-push! (-> ring? any/c void?)] 10 | [ring-pop! (-> ring? (or/c #f any/c))] 11 | [ring-size (-> ring? exact-nonnegative-integer?)])) 12 | 13 | (struct ring (sema vs cap [pos #:mutable] [size #:mutable]) 14 | #:transparent) 15 | 16 | (define (make-ring cap) 17 | (ring (make-semaphore 1) 18 | (make-vector cap #f) 19 | cap 20 | 0 21 | 0)) 22 | 23 | (define (ring-push! r v) 24 | (call-with-semaphore (ring-sema r) 25 | (lambda () 26 | (define vs (ring-vs r)) 27 | (define cap (ring-cap r)) 28 | (define pos (ring-pos r)) 29 | (vector-set! vs pos v) 30 | (set-ring-pos! r (modulo (add1 pos) cap)) 31 | (set-ring-size! r (min cap (add1 (ring-size r))))))) 32 | 33 | (define (ring-pop! r) 34 | (call-with-semaphore (ring-sema r) 35 | (lambda () 36 | (cond 37 | [(zero? (ring-size r)) #f] 38 | [else 39 | (define vs (ring-vs r)) 40 | (define cap (ring-cap r)) 41 | (define pos (if (zero? (ring-pos r)) 42 | (sub1 cap) 43 | (sub1 (ring-pos r)))) 44 | (begin0 (vector-ref vs pos) 45 | (vector-set! vs pos #f) 46 | (set-ring-pos! r pos) 47 | (set-ring-size! r (sub1 (ring-size r))))])))) 48 | -------------------------------------------------------------------------------- /core/schema.rkt: -------------------------------------------------------------------------------- 1 | #lang racket/base 2 | 3 | (require (for-syntax racket/base) 4 | db 5 | racket/file 6 | racket/list 7 | racket/path 8 | racket/runtime-path 9 | racket/string 10 | "database.rkt") 11 | 12 | (provide 13 | migrate!) 14 | 15 | (define-runtime-path migrations-path 16 | (build-path 'up "migrations")) 17 | 18 | (define migration-paths 19 | (sort 20 | (find-files 21 | (lambda (p) 22 | (string-suffix? (path->string p) ".sql")) 23 | (normalize-path migrations-path)) 24 | (lambda (a b) 25 | (string-cistring a) 26 | (path->string b))))) 27 | 28 | (define (migrate!) 29 | (call-with-database-connection 30 | (lambda (conn) 31 | (query-exec conn "create table if not exists schema_migrations(ref text not null unique)") 32 | (for ([migration-path (in-list migration-paths)]) 33 | (define ref (path->string (last (explode-path migration-path)))) 34 | (unless (query-maybe-value conn "select true from schema_migrations where ref = $1" ref) 35 | ;; This is pretty piggy but db-lib doesn't support multiple statements per query. 36 | (define migration (file->string migration-path)) 37 | (define statements (string-split migration ";\n")) 38 | (for ([statement (in-list statements)]) 39 | (when (non-empty-string? (string-trim statement)) 40 | (query-exec conn statement))) 41 | (query-exec conn "insert into schema_migrations values($1)" ref)))))) 42 | -------------------------------------------------------------------------------- /core/tag.rkt: -------------------------------------------------------------------------------- 1 | #lang racket/base 2 | 3 | (require db 4 | deta 5 | racket/contract/base 6 | racket/string 7 | threading 8 | "database.rkt") 9 | 10 | (provide 11 | (contract-out 12 | [assign-tags! (-> id/c (listof non-empty-string?) void?)] 13 | [find-tags-by-entry-id (-> connection? id/c (listof non-empty-string?))])) 14 | 15 | (define-schema tag 16 | #:table "tags" 17 | ([id id/f #:primary-key #:auto-increment] 18 | [name string/f #:contract non-empty-string? #:wrapper string-trim])) 19 | 20 | (define-schema entry-tag 21 | #:table "entry_tags" 22 | ([entry-id id/f] 23 | [tag-id id/f])) 24 | 25 | (define (assign-tags! entry-id tags) 26 | (unless (null? tags) 27 | (call-with-database-transaction 28 | (lambda (conn) 29 | (for ([name (in-list tags)]) 30 | (define t 31 | (or (~> (from tag #:as t) 32 | (where (= t.name ,name)) 33 | (limit 1) 34 | (lookup conn _)) 35 | (~> (make-tag #:name name) 36 | (insert-one! conn _)))) 37 | (unless (~> (from entry-tag #:as et) 38 | (where (and (= et.entry-id ,entry-id) 39 | (= et.tag-id ,(tag-id t)))) 40 | (lookup conn _)) 41 | (~> (make-entry-tag #:entry-id entry-id 42 | #:tag-id (tag-id t)) 43 | (insert-one! conn _)))))))) 44 | 45 | (define (find-tags-by-entry-id conn id) 46 | (~> (from entry-tag #:as et) 47 | (join tag #:as t #:on (= t.id et.tag-id)) 48 | (select t.name) 49 | (where (= et.entry-id ,id)) 50 | (query-list conn _))) 51 | -------------------------------------------------------------------------------- /core/timezone.rkt: -------------------------------------------------------------------------------- 1 | #lang racket/base 2 | 3 | (require ffi/unsafe/nsstring 4 | ffi/unsafe/objc) 5 | 6 | (provide 7 | get-current-system-timezone) 8 | 9 | (import-class NSTimeZone) 10 | 11 | ;; On iOS, gregor has trouble determining the system timezone, so let's 12 | ;; always just ask the system directly instead. 13 | (define (get-current-system-timezone) 14 | (tell #:type _NSString 15 | (tell NSTimeZone systemTimeZone) 16 | name)) 17 | -------------------------------------------------------------------------------- /core/undo.rkt: -------------------------------------------------------------------------------- 1 | #lang racket/base 2 | 3 | (require racket/contract/base 4 | "ring.rkt") 5 | 6 | (provide 7 | (contract-out 8 | [current-undo-ring (parameter/c ring?)] 9 | [push-undo! (-> (-> any) void?)] 10 | [undo! (-> void?)])) 11 | 12 | (define current-undo-ring 13 | (make-parameter (make-ring 128))) 14 | 15 | (define (push-undo! proc) 16 | (ring-push! (current-undo-ring) proc)) 17 | 18 | (define (undo!) 19 | (define proc (ring-pop! (current-undo-ring))) 20 | (when proc (void (proc)))) 21 | -------------------------------------------------------------------------------- /manual/index.scrbl: -------------------------------------------------------------------------------- 1 | #lang scribble/manual 2 | 3 | @(require "shortcut.rkt") 4 | 5 | @title{Remember: Stash distractions away} 6 | @author[(author+email "Bogdan Popa" "bogdan@defn.io")] 7 | 8 | @(define (homepage-anchor text) 9 | (link "https://remember.defn.io" text)) 10 | 11 | @homepage-anchor{Remember} is meant to be operated entirely via the 12 | keyboard. While very convenient once you're used to it, this makes it 13 | challenging to get started. This document describes the features 14 | available in Remember and their operation. 15 | 16 | @(define-syntax-rule (kbd sym ...) 17 | (let ([text (shortcut 'sym ...)]) 18 | (elemref `("kbd" ,text) text))) 19 | 20 | @(define-syntax-rule (defkbd (sym ...) pre-content ...) 21 | (let ([text (shortcut 'sym ...)]) 22 | (elem 23 | (elemtag `("kbd" ,text)) 24 | text 25 | " --- " 26 | pre-content ...))) 27 | 28 | @section{Reading This Document} 29 | 30 | Because Remember is keyboard-driven, this document contains many 31 | keyboard shortcuts. Shortcuts are written using the textual 32 | representation of each key. When you see a shortcut like @kbd[opt 33 | space], you should interpret it as holding down the Option key and 34 | pressing the Space key. 35 | 36 | @tabular[ 37 | #:style 'boxed 38 | #:row-properties '(bottom-border ()) 39 | (list 40 | (list @bold{Name} @bold{Symbol}) 41 | (list @elem{Control} @"⌃") 42 | (list @elem{Option} @"⌥") 43 | (list @elem{Command} @"⌘") 44 | (list @elem{Space} @"⎵") 45 | (list @elem{Delete} @"⌫") 46 | (list @elem{Return} @"↩") 47 | (list @elem{Escape} @"⎋")) 48 | ] 49 | 50 | @subsection{Definitions} 51 | 52 | The @deftech{input area} is the text input in which you type 53 | reminders. 54 | 55 | The @deftech{current desktop} is the desktop or screen on which the 56 | mouse cursor is currently sitting. 57 | 58 | 59 | @section{Basics} 60 | 61 | @defkbd[(opt space)]{ 62 | Pressing this shortcut shows or hides Remember. If Remember is 63 | running, it will appear on the @tech{current desktop} whenever you 64 | press this shortcut. 65 | 66 | You can customize this shortcut using the Preferences pane (@kbd[cmd 67 | comma]). 68 | } 69 | 70 | @defkbd[(cmd Q)]{ 71 | Pressing this shortcut quits the application. 72 | } 73 | 74 | @defkbd[(cmd comma)]{ 75 | Pressing this shortcut opens the Preferences dialog. 76 | } 77 | 78 | 79 | @section{Navigation & Editing} 80 | 81 | @defkbd[(return)]{ 82 | If the @tech{input area} contains text, pressing return creates a 83 | new reminder. If an existing reminder is selected, pressing return 84 | opens the reminder for editing, and pressing return again commits 85 | the change. 86 | } 87 | 88 | @defkbd[(escape)]{ 89 | If the @tech{input area} contains text, pressing escape clears it. 90 | If the input area is already empty, then pressing escape dismisses 91 | Remember. 92 | } 93 | 94 | @defkbd[(ctl P)]{ 95 | Selects the previous reminder. 96 | } 97 | 98 | @defkbd[(ctl N)]{ 99 | Selects the next reminder. 100 | } 101 | 102 | @defkbd[(delete)]{ 103 | If an existing reminder is selected, pressing delete archives it. 104 | @tech{Recurring reminders} are reset instead. 105 | } 106 | 107 | @defkbd[(opt delete)]{ 108 | If an existing reminder is selected, pressing @kbd[opt delete] 109 | deletes it. 110 | } 111 | 112 | @defkbd[(cmd Z)]{ 113 | Undoes the previous action. 114 | } 115 | 116 | 117 | @section{Date/time Modifiers} 118 | 119 | Remember recognizes a small set of special modifiers in your reminders 120 | that control if and when it notifies you about them. For example, a 121 | reminder like @centered{@verbatim{buy milk +30m *every 2 weeks*}} 122 | would fire 30 minutes from when it is created and then once every 2 123 | weeks at the same time of day. 124 | 125 | @subsection{Relative Modifiers} 126 | 127 | A @litchar{+} character followed by a positive number and an interval 128 | suffix instructs Remember to notify you after a specific amount of 129 | time. The supported intervals are: 130 | 131 | @itemlist[ 132 | @item{@tt{m} --- for minutes, eg. @tt{+10m} meaning 10 minutes from now} 133 | @item{@tt{h} --- for hours, eg. @tt{+2h} meaning 2 hours from now} 134 | @item{@tt{d} --- for days, eg. @tt{+1d} meaning 1 day from now} 135 | @item{@tt{w} --- for weeks, eg. @tt{+1w} meaning 1 week from now} 136 | @item{@tt{M} --- for months, eg. @tt{+6M} meaning 6 months from now} 137 | ] 138 | 139 | @subsection{Exact Modifiers} 140 | 141 | A @litchar[@"@"] character followed by a time and an optional day of 142 | the week instructs Remember to notify you at an exact time of day. 143 | Some examples: 144 | 145 | @itemlist[ 146 | @item{@tt[@"@10am"] --- sets a reminder for 10am} 147 | @item{@tt[@"@10:30pm"] --- sets a reminder for 10:30pm} 148 | @item{@tt[@"@22:30"] --- the same as above, but using military time} 149 | @item{@tt[@"@10am tomorrow"] --- sets a reminder for 10 the following day} 150 | @item{@tt[@"@10am tmw"] --- a shorthand for the above} 151 | @item{@tt[@"@8pm mon"] --- sets a reminder for 8pm the following Monday} 152 | ] 153 | 154 | If you don't specify a day of the week and that time has already 155 | passed in the current day, then Remember implicitly considers the 156 | reminder to be for the following day. For example, say it is 157 | currently 11am, the reminder @centered{@verbatim[@"buy milk @10am"]} 158 | will fire at 10am the next day. 159 | 160 | @subsection{Recurring Reminders} 161 | 162 | The following modifiers create @tech{recurring reminders}: 163 | 164 | @itemlist[ 165 | @item{@tt{*hourly*}} 166 | @item{@tt{*daily*}} 167 | @item{@tt{*weekly*}} 168 | @item{@tt{*monthly*}} 169 | @item{@tt{*yearly*}} 170 | @item{@tt{*every N hours*}} 171 | @item{@tt{*every N days*}} 172 | @item{@tt{*every N weeks*}} 173 | @item{@tt{*every N months*}} 174 | @item{@tt{*every N years*}} 175 | ] 176 | 177 | Where @tt{N} is any positive number. 178 | 179 | @deftech{Recurring reminders} repeat at the same time of day after 180 | some interval. Archiving a recurring reminder (@kbd[delete]) resets 181 | it for the next interval. 182 | 183 | 184 | @subsection{Postponing Reminders} 185 | 186 | When editing a reminder (@kbd[return]), you can postpone it by 187 | applying another modifier to it. For example, if you create a 188 | reminder like @centered{@verbatim{buy milk +20m}} and then edit it 189 | five minutes later to add another modifier like 190 | @centered{@verbatim{buy milk +60m}} then the two modifiers stack up 191 | and you will be reminded about it after 75 minutes. 192 | 193 | If you make a mistake, you can undo the change with @kbd[cmd Z]. 194 | -------------------------------------------------------------------------------- /manual/info.rkt: -------------------------------------------------------------------------------- 1 | #lang info 2 | 3 | (define collection "remember") 4 | (define scribblings '(("remember.scrbl"))) 5 | -------------------------------------------------------------------------------- /manual/shortcut.rkt: -------------------------------------------------------------------------------- 1 | #lang racket/base 2 | 3 | (require racket/string) 4 | 5 | (provide 6 | shortcut) 7 | 8 | (define (sym->str s) 9 | (case s 10 | [(ctl) "⌃"] 11 | [(opt) "⌥"] 12 | [(cmd) "⌘"] 13 | [(space) "⎵"] 14 | [(shift) "⇧"] 15 | [(return) "↩"] 16 | [(delete) "⌫"] 17 | [(escape) "⎋"] 18 | [(comma) ","] 19 | [(up) "↑"] 20 | [(down) "↓"] 21 | [else (symbol->string s)])) 22 | 23 | (define (shortcut . syms) 24 | (define strs (map sym->str syms)) 25 | (string-join strs " ")) 26 | -------------------------------------------------------------------------------- /migrations/0001-create-entries-table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE entries( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | title TEXT NOT NULL, 4 | body TEXT NOT NULL, 5 | status TEXT NOT NULL, 6 | due_at TEXT, 7 | created_at TEXT NOT NULL 8 | ); 9 | -------------------------------------------------------------------------------- /migrations/0002-create-entries-status-due-at-index.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX entries_status_due_at_idx ON entries(status, due_at ASC) 2 | -------------------------------------------------------------------------------- /migrations/0003-create-tags-table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE tags( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | name TEXT UNIQUE NOT NULL 4 | ); 5 | -------------------------------------------------------------------------------- /migrations/0004-create-entry-tags-table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE entry_tags( 2 | entry_id INTEGER REFERENCES entries(id) ON DELETE CASCADE, 3 | tag_id INTEGER REFERENCES tags(id) ON DELETE CASCADE 4 | ); 5 | -------------------------------------------------------------------------------- /migrations/0005-add-recurrence-columns-to-entries.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE entries ADD COLUMN next_recurrence_at TEXT; 2 | ALTER TABLE entries ADD COLUMN recurrence_delta INTEGER; 3 | ALTER TABLE entries ADD COLUMN recurrence_modifier INTEGER NULLABLE; 4 | -------------------------------------------------------------------------------- /migrations/0006-add-entry-updated-at-column.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE entries ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''; 2 | UPDATE entries SET updated_at = STRFTIME('%Y-%m-%dT%H:%M:%f'); 3 | -------------------------------------------------------------------------------- /migrations/0007-add-entry-tags-unique-index.sql: -------------------------------------------------------------------------------- 1 | CREATE UNIQUE INDEX entry_tags_idx ON entry_tags(entry_id, tag_id); 2 | -------------------------------------------------------------------------------- /remember-ios/AddReminderIntent.swift: -------------------------------------------------------------------------------- 1 | import AppIntents 2 | import Foundation 3 | 4 | struct AddReminderIntent: AppIntent { 5 | static var title: LocalizedStringResource = "Add a reminder" 6 | 7 | @Parameter(title: "Reminder") 8 | var reminder: String? 9 | 10 | func perform() async throws -> some ProvidesDialog { 11 | var text = self.reminder 12 | if text == nil { 13 | text = try await $reminder.requestValue("What would you like to be reminded about?") 14 | } 15 | _ = try await Backend.shared.commit(command: text!) 16 | return .result(dialog: "OK. I've added a reminder.") 17 | } 18 | } 19 | 20 | struct AddReminderShortcut: AppShortcutsProvider { 21 | static var appShortcuts: [AppShortcut] { 22 | AppShortcut( 23 | intent: AddReminderIntent(), 24 | phrases: [ 25 | "Add a reminder in \(.applicationName)", 26 | "Add a reminder to \(.applicationName)", 27 | "\(.applicationName) to \(\.$reminder)", 28 | "Remind me to \(\.$reminder) in \(.applicationName)" 29 | ], 30 | shortTitle: "Add Reminder", 31 | systemImageName: "plus.app" 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /remember-ios/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /remember-ios/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /remember-ios/Assets.xcassets/AppIcon.appiconset/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bogdanp/remember/e5749f58776ad7f00a0ecfe5f4db8a5c6d9b5ffd/remember-ios/Assets.xcassets/AppIcon.appiconset/icon.png -------------------------------------------------------------------------------- /remember-ios/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /remember-ios/BackendExtensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | // - MARK: Backend 5 | extension Backend { 6 | static let shared = Backend( 7 | withZo: Bundle.main.url(forResource: "res/core", withExtension: "zo")!, 8 | andMod: "main", 9 | andProc: "main" 10 | ) 11 | } 12 | 13 | // - MARK: Entry 14 | extension Entry: Hashable, Identifiable { 15 | public func hash(into hasher: inout Hasher) { 16 | hasher.combine(id) 17 | } 18 | 19 | public static func == (lhs: Entry, rhs: Entry) -> Bool { 20 | return lhs.id == rhs.id 21 | } 22 | 23 | var notificationId: String { 24 | "io.defn.remember-ios.Entry.\(id)" 25 | } 26 | } 27 | 28 | // - MARK: Token 29 | extension Token { 30 | var color: Color { 31 | switch self.data { 32 | case .relativeTime(_, _): 33 | return .blue 34 | case .namedDatetime(_): 35 | return .blue 36 | case .namedDate(_): 37 | return .blue 38 | case .recurrence(_, _): 39 | return .green 40 | case .tag(_): 41 | return .red 42 | default: 43 | return .primary 44 | } 45 | } 46 | 47 | var range: NSRange { 48 | return NSRange( 49 | location: Int(span.lo.offset), 50 | length: Int(span.hi.offset - span.lo.offset)) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /remember-ios/CommandField.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct CommandField: UIViewRepresentable { 4 | typealias UIViewType = CommandTextField 5 | 6 | @Binding var text: String 7 | 8 | init(_ text: Binding) { 9 | _text = text 10 | } 11 | 12 | func makeUIView(context: Context) -> CommandTextField { 13 | let toolbar = UIToolbar() 14 | toolbar.barStyle = .default 15 | toolbar.items = [ 16 | UIBarButtonItem( 17 | title: "@", 18 | style: .plain, 19 | target: context.coordinator, 20 | action: #selector(Coordinator.didPressToolbarAtButton(sender:)) 21 | ), 22 | UIBarButtonItem( 23 | title: "+", 24 | style: .plain, 25 | target: context.coordinator, 26 | action: #selector(Coordinator.didPressToolbarPlusButton(sender:)) 27 | ), 28 | UIBarButtonItem( 29 | title: "*", 30 | style: .plain, 31 | target: context.coordinator, 32 | action: #selector(Coordinator.didPressToolbarTimesButton(sender:)) 33 | ), 34 | ] 35 | toolbar.isTranslucent = true 36 | toolbar.translatesAutoresizingMaskIntoConstraints = false 37 | toolbar.sizeToFit() 38 | 39 | let field = CommandTextField() 40 | field.allowsEditingTextAttributes = true 41 | field.backgroundColor = UIColor.clear 42 | field.inputAccessoryView = toolbar 43 | field.placeholder = "Remember..." 44 | field.text = text 45 | field.addTarget( 46 | context.coordinator, 47 | action: #selector(Coordinator.textFieldDidChange(sender:)), 48 | for: .editingChanged) 49 | return field 50 | } 51 | 52 | func updateUIView(_ field: CommandTextField, context: Context) { 53 | if field.text != text { 54 | field.text = text 55 | context.coordinator.scheduleHighlight(forTextField: field) 56 | } 57 | } 58 | 59 | func makeCoordinator() -> Coordinator { 60 | return Coordinator($text) 61 | } 62 | 63 | final class Coordinator: NSObject { 64 | @Binding var text: String 65 | 66 | private var timer: Timer? 67 | 68 | init(_ text: Binding) { 69 | _text = text 70 | } 71 | 72 | func scheduleHighlight(forTextField field: UITextField) { 73 | timer?.invalidate() 74 | timer = .scheduledTimer(withTimeInterval: 1.0/30.0, repeats: false) { _ in 75 | RunLoop.main.schedule { [weak self] in 76 | self?.highlight(textField: field) 77 | } 78 | } 79 | } 80 | 81 | func highlight(textField field: UITextField) { 82 | guard let text = field.text, text != "" else { return } 83 | guard let tokens = try? Backend.shared.parse(command: text).wait() else { return } 84 | let attributedText = NSMutableAttributedString(string: text) 85 | attributedText.beginEditing() 86 | for token in tokens { 87 | attributedText.addAttribute( 88 | .foregroundColor, 89 | value: UIColor(token.color), 90 | range: token.range) 91 | } 92 | attributedText.endEditing() 93 | field.attributedText = attributedText 94 | } 95 | 96 | @objc func didPressToolbarAtButton(sender: Any) { 97 | text += "@" 98 | } 99 | 100 | @objc func didPressToolbarTimesButton(sender: Any) { 101 | text += "*" 102 | } 103 | 104 | @objc func didPressToolbarPlusButton(sender: Any) { 105 | text += "+" 106 | } 107 | 108 | @objc func textFieldDidChange(sender: UITextField) { 109 | scheduleHighlight(forTextField: sender) 110 | text = sender.text ?? "" 111 | } 112 | } 113 | } 114 | 115 | // - MARK: CommandTextField 116 | class CommandTextField: UITextField { 117 | } 118 | -------------------------------------------------------------------------------- /remember-ios/CommandView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct CommandView: View { 4 | @Environment(\.dismiss) private var dismiss 5 | 6 | @State private var command = "" 7 | @FocusState private var focused 8 | 9 | var body: some View { 10 | NavigationView { 11 | VStack(alignment: .leading) { 12 | CommandField($command) 13 | .focused($focused) 14 | .frame(width: nil, height: 48) 15 | Spacer() 16 | } 17 | .padding() 18 | .navigationTitle("New Reminder") 19 | .toolbar { 20 | ToolbarItem(placement: .cancellationAction) { 21 | Button("Cancel") { 22 | dismiss() 23 | } 24 | } 25 | ToolbarItem(placement: .confirmationAction) { 26 | Button("Add") { 27 | Backend.shared.commit(command: command).onComplete { _ in 28 | dismiss() 29 | } 30 | } 31 | } 32 | } 33 | }.onAppear { 34 | focused = true 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /remember-ios/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ContentView: View { 4 | @Environment(\.scenePhase) private var scenePhase 5 | @ObservedObject private var store = Store() 6 | @State private var tab = "home" 7 | @State private var bgTab = "home" 8 | @State private var presentSheet = false 9 | 10 | var body: some View { 11 | TabView(selection: $tab) { 12 | RemindersView(store: store) 13 | .tabItem { Image(systemName: "house.fill") } 14 | .tag("home") 15 | 16 | Text("") 17 | .tabItem { Image(systemName: "plus.app.fill") } 18 | .tag("new-reminder") 19 | 20 | SettingsView() 21 | .tabItem { Image(systemName: "gearshape.fill") } 22 | .tag("settings") 23 | } 24 | .sheet(isPresented: $presentSheet, onDismiss: { 25 | tab = bgTab 26 | }, content: { 27 | CommandView() 28 | }) 29 | .onChange(of: tab) { 30 | if tab == "new-reminder" { 31 | presentSheet = true 32 | tab = bgTab 33 | } else { 34 | bgTab = tab 35 | } 36 | } 37 | .onChange(of: scenePhase) { 38 | switch scenePhase { 39 | case .background: 40 | store.invalidate() 41 | FolderSyncer.shared.invalidate() 42 | FolderSyncer.shared.scheduleRefresh() 43 | NotificationsManager.shared.scheduleRefresh() 44 | case .inactive: 45 | store.invalidate() 46 | FolderSyncer.shared.invalidate() 47 | case .active: 48 | store.scheduleLoadEntries() 49 | FolderSyncer.shared.scheduleSync() 50 | FolderSyncer.shared.unscheduleRefresh() 51 | NotificationsManager.shared.unscheduleRefresh() 52 | @unknown default: 53 | fatalError() 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /remember-ios/DeviceShakeViewModifier.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | extension UIDevice { 5 | static let deviceDidShakeNotification = Notification.Name(rawValue: "deviceDidShakeNotification") 6 | } 7 | 8 | extension UIWindow { 9 | open override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { 10 | if motion == .motionShake { 11 | NotificationCenter.default.post( 12 | name: UIDevice.deviceDidShakeNotification, 13 | object: event) 14 | } else { 15 | super.motionEnded(motion, with: event) 16 | } 17 | } 18 | } 19 | 20 | struct DeviceShakeViewModifier: ViewModifier { 21 | let action: (UIEvent?) -> Void 22 | 23 | func body(content: Content) -> some View { 24 | content.onReceive(NotificationCenter.default.publisher(for: UIDevice.deviceDidShakeNotification)) { notification in 25 | action(notification.object as? UIEvent ) 26 | } 27 | } 28 | } 29 | 30 | extension View { 31 | func onShake(perform action: @escaping (UIEvent?) -> Void) -> some View { 32 | self.modifier(DeviceShakeViewModifier(action: action)) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /remember-ios/FolderSyncer.swift: -------------------------------------------------------------------------------- 1 | import BackgroundTasks 2 | import Foundation 3 | import os 4 | 5 | fileprivate let logger = Logger( 6 | subsystem: "io.defn.remember", 7 | category: "FolderSyncer" 8 | ) 9 | 10 | class FolderSyncer { 11 | static let shared = FolderSyncer() 12 | 13 | static private let refreshIdentifier = "io.defn.remember.FolderSyncer.refresh" 14 | 15 | init() { 16 | sync() 17 | scheduleSync() 18 | } 19 | 20 | private var timer: Timer? 21 | private var id: String { 22 | if let id = UserDefaults.standard.string(forKey: "sync.id") { 23 | return id 24 | } 25 | 26 | let id = UUID().uuidString 27 | UserDefaults.standard.set(id, forKey: "sync.id") 28 | return id 29 | } 30 | 31 | func registerTasks() { 32 | logger.debug("Registering refresh task.") 33 | BGTaskScheduler.shared.register( 34 | forTaskWithIdentifier: Self.refreshIdentifier, 35 | using: nil) { [weak self] task in 36 | logger.debug("Handling refresh.") 37 | self?.handleRefresh(task as! BGAppRefreshTask) 38 | } 39 | } 40 | 41 | func scheduleRefresh() { 42 | logger.debug("Preparing to schedule refresh.") 43 | let request = BGAppRefreshTaskRequest(identifier: Self.refreshIdentifier) 44 | let deadline = Date(timeIntervalSinceNow: 5*60) 45 | request.earliestBeginDate = deadline 46 | logger.debug("Scheduling refresh at \(deadline.ISO8601Format()).") 47 | do { 48 | try BGTaskScheduler.shared.submit(request) 49 | } catch { 50 | logger.error("Failed to schedule refresh: \(error)") 51 | } 52 | } 53 | 54 | func unscheduleRefresh() { 55 | logger.debug("Cancelling scheduled refresh tasks.") 56 | BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.refreshIdentifier) 57 | } 58 | 59 | private func handleRefresh(_ task: BGAppRefreshTask) { 60 | task.expirationHandler = { 61 | task.setTaskCompleted(success: false) 62 | } 63 | scheduleRefresh() 64 | sync() { 65 | NotificationsManager.shared.scheduleRefresh() 66 | task.setTaskCompleted(success: true) 67 | } 68 | } 69 | 70 | func invalidate() { 71 | timer?.invalidate() 72 | } 73 | 74 | func scheduleSync() { 75 | timer?.invalidate() 76 | timer = Timer.scheduledTimer( 77 | withTimeInterval: 5*60, 78 | repeats: true 79 | ) { [weak self] _ in 80 | self?.sync() 81 | } 82 | } 83 | 84 | func sync(withCompletionHandler completionHandler: @escaping () -> Void = { }) { 85 | do { 86 | if let path = try FolderSyncDefaults.load() { 87 | logger.debug("Initiating sync to \(path).") 88 | Backend.shared.createDatabaseCopy().onComplete { [weak self] tempPath in 89 | guard let self else { return } 90 | guard let tempURL = URL(string: tempPath) else { return } 91 | if path.startAccessingSecurityScopedResource() { 92 | defer { 93 | path.stopAccessingSecurityScopedResource() 94 | } 95 | 96 | self.performMerge(from: path) 97 | self.performSave(to: path, from: tempURL) 98 | } else { 99 | logger.error("Failed to acquire security access to \(path).") 100 | } 101 | completionHandler() 102 | } 103 | } else { 104 | logger.debug("No sync folder. Doing nothing.") 105 | completionHandler() 106 | } 107 | } catch { 108 | logger.error("Failed to load sync folder: \(error)") 109 | completionHandler() 110 | } 111 | } 112 | 113 | private func performMerge(from root: URL) { 114 | do { 115 | if let latestDB = latestDatabaseFile(from: root), !latestDB.absoluteString.contains(id) { 116 | let manager = FileManager.default 117 | let destPath = try manager.url( 118 | for: .itemReplacementDirectory, 119 | in: .userDomainMask, 120 | appropriateFor: root, 121 | create: true 122 | ).appendingPathComponent("\(id).sqlite3") 123 | 124 | try manager.copyItem(at: latestDB, to: destPath) 125 | try Backend.shared.mergeDatabaseCopy(atPath: destPath.absoluteString).wait() 126 | } 127 | } catch { 128 | logger.error("Failed to perform database merge; \(error)") 129 | } 130 | } 131 | 132 | private func performSave(to root: URL, from tempPath: URL) { 133 | do { 134 | let manager = FileManager.default 135 | let destURL = root.appendingPathComponent("\(id).sqlite3") 136 | let sourceURL = URL(fileURLWithPath: tempPath.absoluteString) 137 | if manager.fileExists(atPath: destURL.relativePath) { 138 | try manager.removeItem(at: destURL) 139 | } 140 | 141 | try manager.moveItem(at: sourceURL, to: destURL) 142 | } catch { 143 | logger.error("Failed to save database to sync folder: \(error)") 144 | } 145 | } 146 | 147 | private func latestDatabaseFile(from root: URL) -> URL? { 148 | do { 149 | let manager = FileManager.default 150 | let entries = try manager.contentsOfDirectory(atPath: root.relativePath) 151 | var latestEntry: URL? 152 | for entry in entries { 153 | if entry.suffix(8) == ".sqlite3" { 154 | if let latest = latestEntry { 155 | let latestAttributes = try manager.attributesOfItem(atPath: latest.relativePath) 156 | let latestModifiedAt = latestAttributes[.modificationDate] as! Date 157 | let entryURL = root.appendingPathComponent(entry) 158 | let entryAttributes = try manager.attributesOfItem(atPath: entryURL.relativePath) 159 | let entryModifiedAt = entryAttributes[.modificationDate] as! Date 160 | 161 | if entryModifiedAt > latestModifiedAt { 162 | latestEntry = entryURL 163 | } 164 | } else { 165 | latestEntry = root.appendingPathComponent(entry) 166 | } 167 | } 168 | } 169 | 170 | return latestEntry 171 | } catch { 172 | logger.error("Failed to find latest database file: \(error)") 173 | return nil 174 | } 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /remember-ios/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BGTaskSchedulerPermittedIdentifiers 6 | 7 | io.defn.remember.NotificationsManager.refresh 8 | io.defn.remember.FolderSyncer.refresh 9 | 10 | ITSAppUsesNonExemptEncryption 11 | 12 | NSUserActivityTypes 13 | 14 | ReminderIntent 15 | 16 | UIBackgroundModes 17 | 18 | fetch 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /remember-ios/NotificationsManager.swift: -------------------------------------------------------------------------------- 1 | import BackgroundTasks 2 | import Foundation 3 | import NoiseSerde 4 | import UserNotifications 5 | import os 6 | 7 | fileprivate let logger = Logger( 8 | subsystem: "io.defn.remember-ios", 9 | category: "NotificationsManager" 10 | ) 11 | 12 | class NotificationsManager: NSObject { 13 | static let shared = NotificationsManager() 14 | 15 | private var entries = [String: Entry]() 16 | 17 | private static let refreshIdentifier = "io.defn.remember.NotificationsManager.refresh" 18 | 19 | override init() { 20 | super.init() 21 | 22 | let center = UNUserNotificationCenter.current() 23 | center.delegate = self 24 | center.requestAuthorization(options: [.alert, .badge, .sound]) { _, error in 25 | guard error == nil else { 26 | logger.error("Did not receive authorization to send notifications: \(error)") 27 | return 28 | } 29 | } 30 | } 31 | 32 | func registerTasks() { 33 | logger.debug("Registering refresh task.") 34 | BGTaskScheduler.shared.register( 35 | forTaskWithIdentifier: Self.refreshIdentifier, 36 | using: nil) { [weak self] task in 37 | self?.handleRefresh(task as! BGAppRefreshTask) 38 | } 39 | } 40 | 41 | func scheduleRefresh() { 42 | logger.debug("Preparing to schedule refresh.") 43 | Backend.shared.getPendingEntries().onComplete { entries in 44 | let now = UVarint(Date().timeIntervalSince1970) 45 | let request = BGAppRefreshTaskRequest(identifier: Self.refreshIdentifier) 46 | var deadline: TimeInterval? = nil 47 | for entry in entries { 48 | if let dueAt = entry.dueAt, dueAt >= now { 49 | deadline = TimeInterval(dueAt) 50 | break 51 | } 52 | } 53 | guard let deadline = deadline else { 54 | logger.warning("No pending tasks, not scheduling refresh.") 55 | return 56 | } 57 | request.earliestBeginDate = Date(timeIntervalSince1970: deadline) 58 | logger.debug("Scheduling refresh at \(request.earliestBeginDate!.ISO8601Format()).") 59 | 60 | do { 61 | try BGTaskScheduler.shared.submit(request) 62 | } catch { 63 | logger.error("Failed to schedule refresh: \(error)") 64 | } 65 | } 66 | } 67 | 68 | func unscheduleRefresh() { 69 | BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.refreshIdentifier) 70 | } 71 | 72 | private func handleRefresh(_ task: BGAppRefreshTask) { 73 | scheduleRefresh() 74 | 75 | let future = Backend.shared.getDueEntries() 76 | future.onComplete { entries in 77 | RunLoop.main.schedule { 78 | self.notify(ofEntries: entries) 79 | task.setTaskCompleted(success: true) 80 | } 81 | } 82 | 83 | task.expirationHandler = { 84 | future.cancel() 85 | task.setTaskCompleted(success: false) 86 | } 87 | } 88 | 89 | func removePendingNotification(for entry: Entry) { 90 | assert(Thread.current.isMainThread) 91 | entries.removeValue(forKey: entry.notificationId) 92 | let center = UNUserNotificationCenter.current() 93 | center.removePendingNotificationRequests(withIdentifiers: [entry.notificationId]) 94 | center.setBadgeCount(entries.count) 95 | } 96 | 97 | func notify(ofEntries entries: [Entry]) { 98 | assert(Thread.current.isMainThread) 99 | let center = UNUserNotificationCenter.current() 100 | center.removePendingNotificationRequests(withIdentifiers: self.entries.map { $1.notificationId }) 101 | center.setBadgeCount(0) 102 | self.entries.removeAll(keepingCapacity: true) 103 | for entry in entries { 104 | self.entries[entry.notificationId] = entry 105 | let content = UNMutableNotificationContent() 106 | content.title = "Remember" 107 | content.body = entry.title 108 | content.badge = NSNumber(value: entries.count) 109 | let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 15, repeats: false) 110 | let request = UNNotificationRequest( 111 | identifier: entry.notificationId, 112 | content: content, 113 | trigger: trigger) 114 | UNUserNotificationCenter 115 | .current() 116 | .add(request) { _ in } 117 | } 118 | center.setBadgeCount(entries.count) 119 | } 120 | } 121 | 122 | extension NotificationsManager: UNUserNotificationCenterDelegate { 123 | func userNotificationCenter( 124 | _ center: UNUserNotificationCenter, 125 | didReceive response: UNNotificationResponse, 126 | withCompletionHandler completionHandler: @escaping () -> Void) { 127 | 128 | RunLoop.main.schedule { [weak self] in 129 | guard let self else { return } 130 | if let entry = self.entries[response.notification.request.identifier] { 131 | self.removePendingNotification(for: entry) 132 | } 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /remember-ios/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /remember-ios/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyAccessedAPITypes 6 | 7 | 8 | NSPrivacyAccessedAPIType 9 | NSPrivacyAccessedAPICategoryUserDefaults 10 | NSPrivacyAccessedAPITypeReasons 11 | 12 | CA92.1 13 | 14 | 15 | 16 | NSPrivacyAccessedAPIType 17 | NSPrivacyAccessedAPICategoryFileTimestamp 18 | NSPrivacyAccessedAPITypeReasons 19 | 20 | 3B52.1 21 | C617.1 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /remember-ios/Remember.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | 4 | @main 5 | struct Remember: App { 6 | private let notifications = NotificationsManager.shared 7 | private let syncer = FolderSyncer.shared 8 | 9 | init() { 10 | NotificationsManager.shared.registerTasks() 11 | FolderSyncer.shared.registerTasks() 12 | } 13 | 14 | var body: some Scene { 15 | WindowGroup { 16 | ContentView() 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /remember-ios/ReminderDetailView.swift: -------------------------------------------------------------------------------- 1 | import NoiseSerde 2 | import SwiftUI 3 | 4 | struct ReminderDetailView: View { 5 | @FocusState private var focused 6 | @State var command: String 7 | let action: (String) -> Void 8 | 9 | var body: some View { 10 | NavigationView { 11 | VStack(alignment: .leading) { 12 | CommandField($command) 13 | .focused($focused) 14 | .frame(width: nil, height: 48) 15 | Spacer() 16 | } 17 | .padding() 18 | } 19 | .toolbar { 20 | ToolbarItem { 21 | Button("Done") { 22 | action(command) 23 | } 24 | } 25 | } 26 | .onAppear { 27 | focused = true 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /remember-ios/RemindersView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct RemindersView: View { 4 | @ObservedObject var store: Store 5 | 6 | @State private var loaded = false 7 | @State private var path = [Entry]() 8 | 9 | var body: some View { 10 | NavigationStack(path: $path) { 11 | if !loaded { 12 | ProgressView() 13 | .padding() 14 | } 15 | List { 16 | ForEach(store.entries) { entry in 17 | NavigationLink(value: entry) { 18 | Reminder( 19 | store: store, 20 | entry: entry 21 | ) 22 | } 23 | } 24 | } 25 | .listStyle(.plain) 26 | .navigationTitle("Remember") 27 | .navigationDestination(for: Entry.self) { entry in 28 | ReminderDetailView(command: entry.title) { command in 29 | store.update(entry: entry, withCommand: command) { 30 | RunLoop.main.schedule { 31 | path = [] 32 | } 33 | } 34 | } 35 | .navigationTitle("Edit Reminder") 36 | .navigationBarTitleDisplayMode(.inline) 37 | } 38 | .refreshable { 39 | FolderSyncer.shared.sync { 40 | store.loadEntries() 41 | } 42 | } 43 | .onShake { _ in 44 | _ = Backend.shared.undo() 45 | } 46 | .onAppear { 47 | if !loaded { 48 | Backend.shared.ping().onComplete { _ in 49 | RunLoop.main.schedule { 50 | loaded = true 51 | } 52 | } 53 | } 54 | } 55 | } 56 | } 57 | } 58 | 59 | fileprivate struct Reminder: View { 60 | @ObservedObject var store: Store 61 | let entry: Entry 62 | 63 | var body: some View { 64 | HStack { 65 | Text(entry.title) 66 | Spacer() 67 | if let dueIn = entry.dueIn { 68 | Text(dueIn) 69 | .font(.footnote) 70 | .foregroundStyle(.secondary) 71 | } 72 | } 73 | .swipeActions(edge: .leading) { 74 | Button(action: { 75 | store.snooze(entry: entry) 76 | }, label: { 77 | Label("Snooze", systemImage: "bell.slash.fill") 78 | }).tint(.secondary) 79 | } 80 | .swipeActions(edge: .trailing) { 81 | Button(action: { 82 | store.archive(entry: entry) 83 | }, label: { 84 | Label("Archive", systemImage: "checkmark.circle") 85 | }).tint(.accentColor) 86 | Button(role: .destructive, action: { 87 | store.delete(entry: entry) 88 | }, label: { 89 | Label("Delete", systemImage: "trash.slash.fill") 90 | }).tint(.red) 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /remember-ios/SettingsSyncView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import os 3 | 4 | fileprivate let logger = Logger( 5 | subsystem: "io.defn.remember-ios", 6 | category: "SettingsSyncView" 7 | ) 8 | 9 | struct SettingsSyncView: View { 10 | @State var sync = (try? FolderSyncDefaults.load()) != nil 11 | @State var presentFileImporter = false 12 | 13 | var body: some View { 14 | List { 15 | Section { 16 | Toggle(isOn: $sync, label: { 17 | Text("Sync") 18 | }).disabled(!sync) 19 | Button { 20 | presentFileImporter.toggle() 21 | } label: { 22 | Text("Select Folder...") 23 | }.fileImporter( 24 | isPresented: $presentFileImporter, 25 | allowedContentTypes: [.folder] 26 | ) { result in 27 | switch result { 28 | case .failure(_): 29 | FolderSyncDefaults.clear() 30 | sync = false 31 | case .success(let url): 32 | do { 33 | try FolderSyncDefaults.save(path: url) 34 | sync = true 35 | } catch { 36 | logger.error("Failed to save sync folder defaults: \(error)") 37 | } 38 | } 39 | } 40 | } 41 | } 42 | .navigationTitle("Sync") 43 | .navigationBarTitleDisplayMode(.inline) 44 | } 45 | } 46 | 47 | 48 | class FolderSyncDefaults { 49 | private static let KEY = "sync.folder" 50 | 51 | static func load() throws -> URL? { 52 | return try UserDefaults.standard.data(forKey: KEY).flatMap { d in 53 | var isStale = false 54 | let url = try URL( 55 | resolvingBookmarkData: d, 56 | bookmarkDataIsStale: &isStale) 57 | if isStale { 58 | return nil 59 | } 60 | 61 | return url 62 | } 63 | } 64 | 65 | static func save(path: URL) throws { 66 | guard path.startAccessingSecurityScopedResource() else { 67 | logger.error("Failed to access security scoped resource.") 68 | return 69 | } 70 | defer { path.stopAccessingSecurityScopedResource() } 71 | let bookmark = try path.bookmarkData( 72 | options: [.minimalBookmark], 73 | includingResourceValuesForKeys: nil, 74 | relativeTo: nil) 75 | UserDefaults.standard.setValue(bookmark, forKey: KEY) 76 | } 77 | 78 | static func clear() { 79 | UserDefaults.standard.removeObject(forKey: KEY) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /remember-ios/SettingsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SettingsView: View { 4 | var body: some View { 5 | NavigationView { 6 | List { 7 | Section(content: { 8 | NavigationLink("Sync") { 9 | SettingsSyncView() 10 | } 11 | }, header: { 12 | Text("General") 13 | }) 14 | 15 | Section(content: { 16 | Button(action: { 17 | UIApplication.shared.open(URL(string: "https://remember.defn.io/manual/")!) 18 | }, label: { 19 | Text("Manual") 20 | }) 21 | HStack { 22 | Text("Version") 23 | Spacer() 24 | Text("1.0.0") 25 | } 26 | }, header: { 27 | Text("About") 28 | }) 29 | } 30 | .navigationTitle("Settings") 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /remember-ios/Store.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import UserNotifications 4 | import os 5 | 6 | fileprivate let logger = Logger( 7 | subsystem: "io.defn.remember-ios", 8 | category: "Store" 9 | ) 10 | 11 | class Store: ObservableObject { 12 | @Published var entries = [Entry]() 13 | 14 | private var timer: Timer? 15 | 16 | init() { 17 | loadEntries() 18 | 19 | Backend.shared.installCallback(entriesDidChangeCb: { [weak self] _ in 20 | logger.debug("Entries changed.") 21 | self?.loadEntries() 22 | }).onComplete { 23 | _ = Backend.shared.markReadyForChanges() 24 | } 25 | 26 | Backend.shared.installCallback(entriesDueCb: { [weak self] entries in 27 | logger.debug("Have \(entries.count) due entries.") 28 | RunLoop.main.schedule { 29 | self?.loadEntries() 30 | NotificationsManager.shared.notify(ofEntries: entries) 31 | } 32 | }).onComplete { 33 | _ = Backend.shared.startScheduler() 34 | } 35 | } 36 | 37 | func archive(entry: Entry) { 38 | Backend.shared.archive(entryWithId: entry.id).onComplete { 39 | NotificationsManager.shared.removePendingNotification(for: entry) 40 | } 41 | } 42 | 43 | func delete(entry: Entry) { 44 | Backend.shared.delete(entryWithId: entry.id).onComplete { 45 | NotificationsManager.shared.removePendingNotification(for: entry) 46 | } 47 | } 48 | 49 | func snooze(entry: Entry) { 50 | Backend.shared.snooze(entryWithId: entry.id, forMinutes: 15).onComplete { 51 | NotificationsManager.shared.removePendingNotification(for: entry) 52 | } 53 | } 54 | 55 | func update( 56 | entry: Entry, 57 | withCommand command: String, 58 | andCompletionHandler completionHandler: @escaping () -> Void = { } 59 | ) { 60 | Backend.shared.update(entryWithId: entry.id, andCommand: command).onComplete { _ in 61 | NotificationsManager.shared.removePendingNotification(for: entry) 62 | completionHandler() 63 | } 64 | } 65 | 66 | func invalidate() { 67 | assert(Thread.current.isMainThread) 68 | logger.debug("Invalidating timer.") 69 | timer?.invalidate() 70 | } 71 | 72 | func scheduleLoadEntries() { 73 | assert(Thread.current.isMainThread) 74 | logger.debug("Scheduling entry load timer.") 75 | timer?.invalidate() 76 | timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in 77 | logger.debug("Entry load timer fired.") 78 | self?.loadEntries() 79 | } 80 | } 81 | 82 | func loadEntries() { 83 | logger.debug("Loading entries.") 84 | Backend.shared.getPendingEntries().onComplete { [weak self] entries in 85 | RunLoop.main.schedule { 86 | self?.entries = entries 87 | Backend.shared.getDueEntries().onComplete { entries in 88 | NotificationsManager.shared.notify(ofEntries: entries) 89 | } 90 | } 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /remember/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // remember 4 | // 5 | // Created by Bogdan Popa on 23/12/2019. 6 | // Copyright © 2019-2024 CLEARTYPE SRL. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import Combine 11 | import SwiftUI 12 | import UserNotifications 13 | import os 14 | 15 | @NSApplicationMain 16 | class AppDelegate: NSObject, NSApplicationDelegate { 17 | var window: NSWindow! 18 | 19 | private var statusItem: NSStatusItem? 20 | 21 | private var syncer: FolderSyncer! 22 | 23 | func applicationDidFinishLaunching(_ aNotification: Notification) { 24 | syncer = FolderSyncer() 25 | syncer.start() 26 | 27 | let contentView = ContentView() 28 | let hostingView = NSHostingView(rootView: contentView) 29 | 30 | window = NSWindow( 31 | contentRect: NSRect(x: 0, y: 0, width: 680, height: 0), 32 | styleMask: [.titled, .fullSizeContentView], 33 | backing: .buffered, 34 | defer: false 35 | ) 36 | window.collectionBehavior = .moveToActiveSpace 37 | window.backgroundColor = .clear 38 | window.isMovableByWindowBackground = true 39 | window.isOpaque = false 40 | window.contentView = hostingView 41 | window.titleVisibility = .hidden 42 | window.titlebarAppearsTransparent = true 43 | window.styleMask = [.titled, .fullSizeContentView] 44 | 45 | setupStatusItem() 46 | setupHotKey() 47 | setupHidingListener() 48 | setupUserNotifications() 49 | 50 | // This serves the same purpose as `windowDidBecomeKey` in `WindowDelegate`. 51 | if !NSApp.isActive { 52 | NSApp.activate(ignoringOtherApps: true) 53 | } 54 | 55 | OnboardingManager.shared.show() 56 | } 57 | 58 | func applicationWillBecomeActive(_ notification: Notification) { 59 | positionWindow() 60 | window.makeKeyAndOrderFront(nil) 61 | } 62 | 63 | func applicationWillResignActive(_ notification: Notification) { 64 | NSApp.hide(nil) 65 | 66 | // Re-position the window in case it was moved around by the user. Re-positioning it 67 | // now prevents it from moving around when the user re-activates the application later. 68 | positionWindow() 69 | } 70 | 71 | func applicationWillTerminate(_ aNotification: Notification) { 72 | UserNotificationsManager.shared.dismissAll() 73 | } 74 | 75 | /// Ensures that the window is always positioned in exactly the same spot. Roughly the same position as Spotlight. 76 | private func positionWindow() { 77 | if let screenFrame = NSScreen.main?.visibleFrame { 78 | let screenWidth = screenFrame.size.width 79 | let screenHeight = screenFrame.size.height 80 | 81 | let x = (screenWidth - window.frame.size.width) / 2 82 | let y = (screenHeight * 0.80) - window.frame.size.height 83 | let f = NSRect(x: x, y: y, width: window.frame.size.width, height: window.frame.size.height) 84 | .offsetBy(dx: screenFrame.origin.x, dy: screenFrame.origin.y) 85 | 86 | window.setFrame(f, display: true) 87 | } 88 | } 89 | 90 | private func setupStatusItem() { 91 | if StatusItemDefaults.shouldShow() { 92 | showStatusItem() 93 | } 94 | 95 | Notifications.observeDidToggleStatusItem { show in 96 | if show { 97 | StatusItemDefaults.show() 98 | self.showStatusItem() 99 | } else { 100 | StatusItemDefaults.hide() 101 | self.hideStatusItem() 102 | } 103 | } 104 | } 105 | 106 | private func showStatusItem() { 107 | statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) 108 | if let button = statusItem?.button { 109 | let icon = NSImage(named: NSImage.Name("StatusBarIcon")) 110 | icon?.isTemplate = true 111 | button.image = icon 112 | button.action = nil 113 | } 114 | 115 | let menu = NSMenu() 116 | menu.addItem(NSMenuItem(title: "Show Remember", action: #selector(showApplicationFromStatusItem(_:)), keyEquivalent: "")) 117 | menu.addItem(NSMenuItem.separator()) 118 | menu.addItem(NSMenuItem(title: "Help...", action: #selector(showHelpFromStatusItem(_:)), keyEquivalent: "")) 119 | menu.addItem(NSMenuItem(title: "Manual...", action: #selector(showManualFromStatusItem(_:)), keyEquivalent: "")) 120 | menu.addItem(NSMenuItem.separator()) 121 | menu.addItem(NSMenuItem(title: "Preferences...", action: #selector(showPreferencesFromStatusItem(_:)), keyEquivalent: ",")) 122 | menu.addItem(NSMenuItem.separator()) 123 | menu.addItem(NSMenuItem(title: "Quit Remember", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) 124 | statusItem?.menu = menu 125 | } 126 | 127 | private func hideStatusItem() { 128 | if let item = statusItem { 129 | NSStatusBar.system.removeStatusItem(item) 130 | } 131 | } 132 | 133 | @objc private func showApplicationFromStatusItem(_ sender: Any) { 134 | NSApp.activate(ignoringOtherApps: true) 135 | } 136 | 137 | @objc private func showHelpFromStatusItem(_ sender: Any) { 138 | NSApp.activate(ignoringOtherApps: true) 139 | showHelp(sender) 140 | } 141 | 142 | @objc private func showManualFromStatusItem(_ sender: Any) { 143 | if let url = Bundle.main.url(forResource: "res/manual/index", withExtension: "html") { 144 | NSWorkspace.shared.open(url) 145 | } 146 | } 147 | 148 | @objc private func showPreferencesFromStatusItem(_ sender: Any) { 149 | NSApp.activate(ignoringOtherApps: true) 150 | showPreferences(sender) 151 | } 152 | 153 | private func setupHotKey() { 154 | KeyboardShortcut.register() 155 | } 156 | 157 | /// Sets up the global hiding listener. This is triggered whenever the user intends to hide the window. 158 | private func setupHidingListener() { 159 | Notifications.observeWillHideWindow { 160 | NSApp.hide(nil) 161 | } 162 | } 163 | 164 | /// Sets up access to the notification center and installs an async notification listener to handle `entries-due` events. 165 | private func setupUserNotifications() { 166 | UserNotificationsManager.shared.setup() 167 | } 168 | 169 | /// Called whenever the user presses ⌘, 170 | @IBAction func showPreferences(_ sender: Any) { 171 | PreferencesManager.shared.show() 172 | } 173 | 174 | /// Called whenever the user presses ⌘? 175 | @IBAction func showHelp(_ sender: Any) { 176 | OnboardingManager.shared.show(force: true) 177 | } 178 | 179 | /// Called whenever the global hot key changes. 180 | @objc func didChangeHotKey(_ sender: DDHotKeyTextField) { 181 | KeyboardShortcutDefaults(fromHotKey: sender.hotKey).save() 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /remember/Assets.xcassets/AppIcon.appiconset/1024x1024@3x-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bogdanp/remember/e5749f58776ad7f00a0ecfe5f4db8a5c6d9b5ffd/remember/Assets.xcassets/AppIcon.appiconset/1024x1024@3x-1024.png -------------------------------------------------------------------------------- /remember/Assets.xcassets/AppIcon.appiconset/1024x1024@3x-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bogdanp/remember/e5749f58776ad7f00a0ecfe5f4db8a5c6d9b5ffd/remember/Assets.xcassets/AppIcon.appiconset/1024x1024@3x-128.png -------------------------------------------------------------------------------- /remember/Assets.xcassets/AppIcon.appiconset/1024x1024@3x-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bogdanp/remember/e5749f58776ad7f00a0ecfe5f4db8a5c6d9b5ffd/remember/Assets.xcassets/AppIcon.appiconset/1024x1024@3x-16.png -------------------------------------------------------------------------------- /remember/Assets.xcassets/AppIcon.appiconset/1024x1024@3x-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bogdanp/remember/e5749f58776ad7f00a0ecfe5f4db8a5c6d9b5ffd/remember/Assets.xcassets/AppIcon.appiconset/1024x1024@3x-256.png -------------------------------------------------------------------------------- /remember/Assets.xcassets/AppIcon.appiconset/1024x1024@3x-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bogdanp/remember/e5749f58776ad7f00a0ecfe5f4db8a5c6d9b5ffd/remember/Assets.xcassets/AppIcon.appiconset/1024x1024@3x-32.png -------------------------------------------------------------------------------- /remember/Assets.xcassets/AppIcon.appiconset/1024x1024@3x-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bogdanp/remember/e5749f58776ad7f00a0ecfe5f4db8a5c6d9b5ffd/remember/Assets.xcassets/AppIcon.appiconset/1024x1024@3x-512.png -------------------------------------------------------------------------------- /remember/Assets.xcassets/AppIcon.appiconset/1024x1024@3x-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bogdanp/remember/e5749f58776ad7f00a0ecfe5f4db8a5c6d9b5ffd/remember/Assets.xcassets/AppIcon.appiconset/1024x1024@3x-64.png -------------------------------------------------------------------------------- /remember/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "16x16", 5 | "idiom" : "mac", 6 | "filename" : "1024x1024@3x-16.png", 7 | "scale" : "1x" 8 | }, 9 | { 10 | "size" : "16x16", 11 | "idiom" : "mac", 12 | "filename" : "1024x1024@3x-32.png", 13 | "scale" : "2x" 14 | }, 15 | { 16 | "size" : "32x32", 17 | "idiom" : "mac", 18 | "filename" : "1024x1024@3x-32.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "32x32", 23 | "idiom" : "mac", 24 | "filename" : "1024x1024@3x-64.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "128x128", 29 | "idiom" : "mac", 30 | "filename" : "1024x1024@3x-128.png", 31 | "scale" : "1x" 32 | }, 33 | { 34 | "size" : "128x128", 35 | "idiom" : "mac", 36 | "filename" : "1024x1024@3x-256.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "256x256", 41 | "idiom" : "mac", 42 | "filename" : "1024x1024@3x-256.png", 43 | "scale" : "1x" 44 | }, 45 | { 46 | "size" : "256x256", 47 | "idiom" : "mac", 48 | "filename" : "1024x1024@3x-512.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "512x512", 53 | "idiom" : "mac", 54 | "filename" : "1024x1024@3x-512.png", 55 | "scale" : "1x" 56 | }, 57 | { 58 | "size" : "512x512", 59 | "idiom" : "mac", 60 | "filename" : "1024x1024@3x-1024.png", 61 | "scale" : "2x" 62 | } 63 | ], 64 | "info" : { 65 | "version" : 1, 66 | "author" : "xcode" 67 | } 68 | } -------------------------------------------------------------------------------- /remember/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /remember/Assets.xcassets/Icon.imageset/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bogdanp/remember/e5749f58776ad7f00a0ecfe5f4db8a5c6d9b5ffd/remember/Assets.xcassets/Icon.imageset/32x32.png -------------------------------------------------------------------------------- /remember/Assets.xcassets/Icon.imageset/32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bogdanp/remember/e5749f58776ad7f00a0ecfe5f4db8a5c6d9b5ffd/remember/Assets.xcassets/Icon.imageset/32x32@2x.png -------------------------------------------------------------------------------- /remember/Assets.xcassets/Icon.imageset/32x32@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bogdanp/remember/e5749f58776ad7f00a0ecfe5f4db8a5c6d9b5ffd/remember/Assets.xcassets/Icon.imageset/32x32@3x.png -------------------------------------------------------------------------------- /remember/Assets.xcassets/Icon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "32x32.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "32x32@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "32x32@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /remember/Assets.xcassets/OnboardingStep1-1.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "OnboardingStep1-1.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /remember/Assets.xcassets/OnboardingStep1-1.imageset/OnboardingStep1-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bogdanp/remember/e5749f58776ad7f00a0ecfe5f4db8a5c6d9b5ffd/remember/Assets.xcassets/OnboardingStep1-1.imageset/OnboardingStep1-1.png -------------------------------------------------------------------------------- /remember/Assets.xcassets/OnboardingStep1-2.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "OnboardingStep1-2.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /remember/Assets.xcassets/OnboardingStep1-2.imageset/OnboardingStep1-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bogdanp/remember/e5749f58776ad7f00a0ecfe5f4db8a5c6d9b5ffd/remember/Assets.xcassets/OnboardingStep1-2.imageset/OnboardingStep1-2.png -------------------------------------------------------------------------------- /remember/Assets.xcassets/OnboardingStep1-3.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "OnboardingStep1-3.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /remember/Assets.xcassets/OnboardingStep1-3.imageset/OnboardingStep1-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bogdanp/remember/e5749f58776ad7f00a0ecfe5f4db8a5c6d9b5ffd/remember/Assets.xcassets/OnboardingStep1-3.imageset/OnboardingStep1-3.png -------------------------------------------------------------------------------- /remember/Assets.xcassets/OnboardingStep2-1.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "OnboardingStep2-1.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /remember/Assets.xcassets/OnboardingStep2-1.imageset/OnboardingStep2-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bogdanp/remember/e5749f58776ad7f00a0ecfe5f4db8a5c6d9b5ffd/remember/Assets.xcassets/OnboardingStep2-1.imageset/OnboardingStep2-1.png -------------------------------------------------------------------------------- /remember/Assets.xcassets/OnboardingStep2-2.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "OnboardingStep2-2.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /remember/Assets.xcassets/OnboardingStep2-2.imageset/OnboardingStep2-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bogdanp/remember/e5749f58776ad7f00a0ecfe5f4db8a5c6d9b5ffd/remember/Assets.xcassets/OnboardingStep2-2.imageset/OnboardingStep2-2.png -------------------------------------------------------------------------------- /remember/Assets.xcassets/StatusBarIcon.imageset/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bogdanp/remember/e5749f58776ad7f00a0ecfe5f4db8a5c6d9b5ffd/remember/Assets.xcassets/StatusBarIcon.imageset/16x16.png -------------------------------------------------------------------------------- /remember/Assets.xcassets/StatusBarIcon.imageset/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bogdanp/remember/e5749f58776ad7f00a0ecfe5f4db8a5c6d9b5ffd/remember/Assets.xcassets/StatusBarIcon.imageset/32x32.png -------------------------------------------------------------------------------- /remember/Assets.xcassets/StatusBarIcon.imageset/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bogdanp/remember/e5749f58776ad7f00a0ecfe5f4db8a5c6d9b5ffd/remember/Assets.xcassets/StatusBarIcon.imageset/64x64.png -------------------------------------------------------------------------------- /remember/Assets.xcassets/StatusBarIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "16x16.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "32x32.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "64x64.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /remember/BackendExtensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | #if arch(arm64) 5 | let ARCH = "arm64" 6 | #else 7 | let ARCH = "x86_64" 8 | #endif 9 | 10 | // - MARK: Backend 11 | extension Backend { 12 | static let shared = Backend( 13 | withZo: Bundle.main.url(forResource: "res/core-\(ARCH)", withExtension: "zo")!, 14 | andMod: "main", 15 | andProc: "main" 16 | ) 17 | } 18 | 19 | // - MARK: Entry 20 | extension Entry: Identifiable { 21 | } 22 | 23 | // - MARK: Token 24 | extension Token { 25 | var color: Color { 26 | switch self.data { 27 | case .relativeTime(_, _): 28 | return .blue 29 | case .namedDatetime(_): 30 | return .blue 31 | case .namedDate(_): 32 | return .blue 33 | case .recurrence(_, _): 34 | return .green 35 | case .tag(_): 36 | return .red 37 | default: 38 | return .primary 39 | } 40 | } 41 | 42 | var range: NSRange { 43 | return NSRange( 44 | location: Int(span.lo.offset), 45 | length: Int(span.hi.offset - span.lo.offset)) 46 | } 47 | } 48 | 49 | -------------------------------------------------------------------------------- /remember/BridgingHeader.h: -------------------------------------------------------------------------------- 1 | // 2 | // DDHotKey-Bridging-Headder.h 3 | // remember 4 | // 5 | // Created by Bogdan Popa on 27/12/2019. 6 | // Copyright © 2019-2024 CLEARTYPE SRL. All rights reserved. 7 | // 8 | 9 | #ifndef DDHotKey_Bridging_Headder_h 10 | #define DDHotKey_Bridging_Headder_h 11 | 12 | #import "vendor/DDHotKey/DDHotKeyCenter.h" 13 | #import "vendor/DDHotKey/DDHotKeyTextField.h" 14 | #import "vendor/DDHotKey/DDHotKeyUtilities.h" 15 | 16 | #endif /* DDHotKey_Bridging_Headder_h */ 17 | -------------------------------------------------------------------------------- /remember/CommandField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomTextField.swift 3 | // remember 4 | // 5 | // Created by Bogdan Popa on 23/12/2019. 6 | // Copyright © 2019-2024 CLEARTYPE SRL. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | enum CommandAction { 13 | case update(String) 14 | case cancel(String) 15 | case commit(String) 16 | case archive 17 | case delete 18 | case previous 19 | case next 20 | case undo 21 | } 22 | 23 | struct CommandField: NSViewRepresentable { 24 | typealias NSViewType = NSTextField 25 | 26 | @Binding var text: String 27 | 28 | private let action: (CommandAction) -> Void 29 | 30 | init(_ text: Binding, 31 | action theAction: @escaping (CommandAction) -> Void) { 32 | _text = text 33 | action = theAction 34 | } 35 | 36 | func makeNSView(context: NSViewRepresentableContext) -> NSViewType { 37 | let field = CommandTextField() 38 | field.allowsEditingTextAttributes = true 39 | field.backgroundColor = NSColor.clear 40 | field.delegate = context.coordinator 41 | field.font = .systemFont(ofSize: 24) 42 | field.isBordered = false 43 | field.focusRingType = .none 44 | field.placeholderString = "Remember" 45 | 46 | field.keyBindings.append(contentsOf: [ 47 | KeyBinding(withKeyCode: Keycode.z, andModifierFlags: [.command], using: #selector(Coordinator.undo(_:))), 48 | ]) 49 | 50 | return field 51 | } 52 | 53 | func updateNSView(_ nsView: NSViewType, context: NSViewRepresentableContext) { 54 | nsView.stringValue = text 55 | } 56 | 57 | func makeCoordinator() -> Coordinator { 58 | return Coordinator(action: { 59 | self.action($0) 60 | }, setter: { 61 | self.text = $0 62 | }) 63 | } 64 | 65 | final class Coordinator: NSObject, NSTextFieldDelegate { 66 | private var action: (CommandAction) -> Void 67 | private var setter: (String) -> Void 68 | private var timer: Timer? 69 | 70 | var didBecomeFirstResponder = false 71 | 72 | init(action: @escaping (CommandAction) -> Void, 73 | setter: @escaping (String) -> Void) { 74 | 75 | self.action = action 76 | self.setter = setter 77 | } 78 | 79 | func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { 80 | if commandSelector == #selector(NSResponder.insertNewline(_:)) { 81 | action(.commit(control.stringValue)) 82 | return true 83 | } else if commandSelector == #selector(NSResponder.cancelOperation(_:)) { 84 | action(.cancel(control.stringValue)) 85 | return true 86 | } else if commandSelector == #selector(NSResponder.moveUp(_:)) || commandSelector == #selector(NSResponder.insertBacktab(_:)) { 87 | action(.previous) 88 | return true 89 | } else if commandSelector == #selector(NSResponder.moveDown(_:)) || commandSelector == #selector(NSResponder.insertTab(_:)) { 90 | action(.next) 91 | return true 92 | } else if commandSelector == #selector(NSResponder.deleteBackward(_:)) && control.stringValue.isEmpty { 93 | action(.archive) 94 | return true 95 | } else if commandSelector == #selector(NSResponder.deleteWordBackward(_:)) && control.stringValue.isEmpty { 96 | action(.delete) 97 | return true 98 | } 99 | 100 | return false 101 | } 102 | 103 | @objc func undo(_ sender: NSTextField) { 104 | if sender.stringValue.isEmpty { 105 | action(.undo) 106 | } 107 | } 108 | 109 | func scheduleHighlight(ofTextField field: NSTextField) { 110 | timer?.invalidate() 111 | timer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: false) { [weak self] _ in 112 | self?.highlight(textField: field) 113 | } 114 | } 115 | 116 | func highlight(textField field: NSTextField) { 117 | guard !field.stringValue.isEmpty else { return } 118 | guard let tokens = try? Backend.shared.parse(command: field.stringValue).wait() else { return } 119 | let systemFont = NSFont.systemFont(ofSize: 24) 120 | let attributedText = NSMutableAttributedString(string: field.stringValue) 121 | attributedText.beginEditing() 122 | attributedText.setAttributes( 123 | [NSAttributedString.Key.font: systemFont], 124 | range: NSRange(location: 0, length: attributedText.length)) 125 | for token in tokens { 126 | attributedText.addAttribute( 127 | .foregroundColor, 128 | value: NSColor(token.color), 129 | range: token.range) 130 | } 131 | attributedText.endEditing() 132 | field.attributedStringValue = attributedText 133 | } 134 | 135 | func controlTextDidChange(_ aNotification: Notification) { 136 | if let textField = aNotification.object as? NSTextField { 137 | setter(textField.stringValue) 138 | action(.update(textField.stringValue)) 139 | scheduleHighlight(ofTextField: textField) 140 | } 141 | } 142 | } 143 | } 144 | 145 | fileprivate struct KeyBinding { 146 | private let keyCode: UInt16 147 | private let modifierFlags: NSEvent.ModifierFlags 148 | 149 | let selector: Selector 150 | 151 | init(withKeyCode keyCode: UInt16, andModifierFlags modifierFlags: NSEvent.ModifierFlags, using selector: Selector) { 152 | self.keyCode = keyCode 153 | self.modifierFlags = modifierFlags 154 | self.selector = selector 155 | } 156 | 157 | func matches(_ event: NSEvent) -> Bool { 158 | return event.keyCode == keyCode && 159 | event.modifierFlags.contains(modifierFlags) 160 | } 161 | } 162 | 163 | fileprivate class CommandTextField: NSTextField { 164 | var keyBindings = [KeyBinding]() 165 | 166 | override func performKeyEquivalent(with event: NSEvent) -> Bool { 167 | for binding in keyBindings { 168 | if binding.matches(event) { 169 | return NSApp.sendAction(binding.selector, to: delegate, from: self) 170 | } 171 | } 172 | 173 | return super.performKeyEquivalent(with: event) 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /remember/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // remember 4 | // 5 | // Created by Bogdan Popa on 23/12/2019. 6 | // Copyright © 2019-2024 CLEARTYPE SRL. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import SwiftUI 11 | 12 | struct ContentView: View { 13 | @ObservedObject private var store = Store() 14 | 15 | var body: some View { 16 | VStack(alignment: .leading, spacing: nil) { 17 | HStack { 18 | Image("Icon") 19 | .resizable() 20 | .frame(width: 32, height: 32, alignment: .leading) 21 | 22 | CommandField($store.command) { 23 | switch $0 { 24 | case .update(_): 25 | self.store.hideEntries() 26 | case .cancel(_): 27 | self.store.clear() 28 | case .commit(let c): 29 | self.store.commit(command: c) 30 | case .archive: 31 | if self.store.entriesVisible { 32 | self.store.archiveCurrentEntry() 33 | } 34 | case .delete: 35 | if self.store.entriesVisible { 36 | self.store.deleteCurrentEntry() 37 | } 38 | case .previous: 39 | self.store.updatePendingEntries { 40 | self.store.selectPreviousEntry() 41 | } 42 | case .next: 43 | self.store.updatePendingEntries { 44 | self.store.selectNextEntry() 45 | } 46 | case .undo: 47 | self.store.undo() 48 | } 49 | } 50 | } 51 | .padding(.top, 15) 52 | .padding(.leading, 15) 53 | .padding(.trailing, 15) 54 | .padding(.bottom, entriesVisible ? 10 : 15) 55 | 56 | if entriesVisible { 57 | Divider() 58 | EntryList($store.entries, currentEntry: $store.currentEntry) 59 | } 60 | } 61 | .visualEffect() 62 | .frame(width: 680, height: nil, alignment: .leading) 63 | .fixedSize() 64 | .cornerRadius(8) 65 | } 66 | 67 | var entriesVisible: Bool { 68 | store.entriesVisible && !store.entries.isEmpty 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /remember/EntryList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EntryList.swift 3 | // Remember 4 | // 5 | // Created by Bogdan Popa on 30/12/2019. 6 | // Copyright © 2019-2024 CLEARTYPE SRL. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | struct EntryList: View { 13 | private let title = "Pending" 14 | 15 | @Binding var entries: [Entry] 16 | @Binding var currentEntry: Entry? 17 | 18 | init(_ entries: Binding<[Entry]>, currentEntry: Binding) { 19 | _entries = entries 20 | _currentEntry = currentEntry 21 | } 22 | 23 | var body: some View { 24 | VStack(alignment: .leading, spacing: 3) { 25 | HStack { 26 | Text(title.uppercased()) 27 | .font(.caption) 28 | .foregroundColor(.secondary) 29 | Spacer() 30 | Text("\(entries.count)") 31 | .font(.caption) 32 | .foregroundColor(.secondary) 33 | .padding(.leading, 5) 34 | .padding(.trailing, 5) 35 | .overlay( 36 | Capsule(style: .continuous) 37 | .stroke(Color.secondary, lineWidth: 1) 38 | ) 39 | } 40 | .padding(.top, 5) 41 | .padding(.bottom, 5) 42 | .padding(.leading, 10) 43 | .padding(.trailing, 10) 44 | .opacity(0.75) 45 | 46 | ForEach(visibleEntries()) { entry in 47 | EntryListItem(entry, isCurrent: self.currentEntry.map { entry.id == $0.id } ?? false) 48 | } 49 | } 50 | } 51 | 52 | func visibleEntries() -> [Entry] { 53 | guard let currentEntry = self.currentEntry else { 54 | return [] 55 | } 56 | 57 | if let index = entries.firstIndex(where: { $0.id == currentEntry.id }) { 58 | if index - 2 < 0 { 59 | let lo = 0 60 | let hi = min(5, entries.count) 61 | return Array(entries[lo ..< hi]) 62 | } else if index + 2 >= entries.count { 63 | let lo = max(0, entries.count - 5) 64 | let hi = entries.count 65 | return Array(entries[lo ..< hi]) 66 | } else { 67 | let lo = index - 2 68 | let hi = index + 2 69 | return Array(entries[lo ... hi]) 70 | } 71 | } 72 | 73 | return [] 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /remember/EntryListItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EntryListEntry.swift 3 | // Remember 4 | // 5 | // Created by Bogdan Popa on 30/12/2019. 6 | // Copyright © 2019-2024 CLEARTYPE SRL. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | struct EntryListItem: View { 13 | let entry: Entry 14 | let isCurrent: Bool 15 | 16 | init(_ entry: Entry, isCurrent: Bool) { 17 | self.entry = entry 18 | self.isCurrent = isCurrent 19 | } 20 | 21 | var body: some View { 22 | HStack { 23 | Text(entry.title) 24 | Spacer() 25 | if entry.recurs { 26 | Image(nsImage: NSImage(named: NSImage.refreshFreestandingTemplateName)!) 27 | .foregroundColor(isCurrent ? Color.white : Color.secondary) 28 | } 29 | dueIn 30 | } 31 | .frame(width: nil, height: 32, alignment: .center) 32 | .padding(.leading, 10) 33 | .padding(.trailing, 10) 34 | .background(isCurrent ? Color.accentColor : .clear) 35 | .foregroundColor(isCurrent ? Color(NSColor.white) : .primary) 36 | } 37 | 38 | var dueIn: some View { 39 | entry.dueIn.map { text in 40 | Text(text) 41 | .padding(5) 42 | .font(.system(size: 10)) 43 | .foregroundColor(isCurrent ? Color.white : Color.secondary) 44 | .overlay( 45 | Capsule(style: .continuous) 46 | .stroke(isCurrent ? Color.white : Color.secondary, lineWidth: 1) 47 | ) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /remember/FolderSync.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FolderSync.swift 3 | // Remember 4 | // 5 | // Created by Bogdan Popa on 24/01/2020. 6 | // Copyright © 2020-2024 CLEARTYPE SRL. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import os 11 | 12 | fileprivate let logger = Logger( 13 | subsystem: "io.defn.remember", 14 | category: "FolderSyncer" 15 | ) 16 | 17 | class FolderSyncer { 18 | private var timer: Timer? 19 | 20 | func start(withFrequency frequency: TimeInterval = 5 * 60) { 21 | scheduleSync(withFrequency: frequency) 22 | sync() 23 | 24 | Notifications.observeDidRequestSync { [weak self] in 25 | guard let self else { return } 26 | self.scheduleSync(withFrequency: frequency) 27 | self.sync() 28 | } 29 | } 30 | 31 | private func scheduleSync(withFrequency frequency: TimeInterval) { 32 | timer?.invalidate() 33 | timer = Timer.scheduledTimer( 34 | withTimeInterval: frequency, 35 | repeats: true 36 | ) { [weak self] _ in 37 | self?.sync() 38 | } 39 | } 40 | 41 | private func sync() { 42 | do { 43 | if let path = try FolderSyncDefaults.load() { 44 | logger.debug("Initiating sync to \(path).") 45 | Backend.shared.createDatabaseCopy().onComplete { [weak self] tempPath in 46 | guard let self else { return } 47 | guard let tempURL = URL(string: tempPath) else { return } 48 | if path.startAccessingSecurityScopedResource() { 49 | defer { 50 | path.stopAccessingSecurityScopedResource() 51 | } 52 | 53 | self.performMerge(from: path) 54 | self.performSave(to: path, from: tempURL) 55 | } else { 56 | logger.error("Failed to acquire security access to \(path).") 57 | } 58 | } 59 | } 60 | } catch { 61 | logger.error("Failed to load sync folder: \(error)") 62 | } 63 | } 64 | 65 | private func syncId() -> String { 66 | if let id = UserDefaults.standard.string(forKey: "syncId") { 67 | return id 68 | } 69 | 70 | let id = UUID().uuidString 71 | UserDefaults.standard.set(id, forKey: "syncId") 72 | return id 73 | } 74 | 75 | private func performMerge(from root: URL) { 76 | do { 77 | if let latestDB = latestDatabaseFile(from: root), !latestDB.absoluteString.contains(syncId()) { 78 | let manager = FileManager.default 79 | let destPath = try manager.url( 80 | for: .itemReplacementDirectory, 81 | in: .userDomainMask, 82 | appropriateFor: root, 83 | create: true 84 | ).appendingPathComponent("\(syncId()).sqlite3") 85 | 86 | try manager.copyItem(at: latestDB, to: destPath) 87 | try Backend.shared.mergeDatabaseCopy(atPath: destPath.absoluteString).wait() 88 | } 89 | } catch { 90 | logger.error("Failed to perform database merge; \(error)") 91 | } 92 | } 93 | 94 | private func performSave(to root: URL, from tempPath: URL) { 95 | do { 96 | let manager = FileManager.default 97 | let destURL = root.appendingPathComponent("\(self.syncId()).sqlite3") 98 | let sourceURL = URL(fileURLWithPath: tempPath.absoluteString) 99 | if manager.fileExists(atPath: destURL.relativePath) { 100 | try manager.removeItem(at: destURL) 101 | } 102 | 103 | try manager.moveItem(at: sourceURL, to: destURL) 104 | } catch { 105 | logger.error("Failed to save database to sync folder: \(error)") 106 | } 107 | } 108 | 109 | private func latestDatabaseFile(from root: URL) -> URL? { 110 | do { 111 | let manager = FileManager.default 112 | let entries = try manager.contentsOfDirectory(atPath: root.relativePath) 113 | var latestEntry: URL? 114 | for entry in entries { 115 | if entry.suffix(8) == ".sqlite3" { 116 | if let latest = latestEntry { 117 | let latestAttributes = try manager.attributesOfItem(atPath: latest.relativePath) 118 | let latestModifiedAt = latestAttributes[.modificationDate] as! Date 119 | let entryURL = root.appendingPathComponent(entry) 120 | let entryAttributes = try manager.attributesOfItem(atPath: entryURL.relativePath) 121 | let entryModifiedAt = entryAttributes[.modificationDate] as! Date 122 | 123 | if entryModifiedAt > latestModifiedAt { 124 | latestEntry = entryURL 125 | } 126 | } else { 127 | latestEntry = root.appendingPathComponent(entry) 128 | } 129 | } 130 | } 131 | 132 | return latestEntry 133 | } catch { 134 | logger.error("Failed to find latest database file: \(error)") 135 | return nil 136 | } 137 | } 138 | } 139 | 140 | class FolderSyncDefaults { 141 | private static let KEY = "sync" 142 | 143 | static func load() throws -> URL? { 144 | return try UserDefaults.standard.data(forKey: KEY).flatMap { d in 145 | var isStale = false 146 | let url = try URL( 147 | resolvingBookmarkData: d, 148 | options: [.withSecurityScope, .withoutUI], 149 | relativeTo: nil, 150 | bookmarkDataIsStale: &isStale) 151 | if isStale { 152 | return nil 153 | } 154 | 155 | return url 156 | } 157 | } 158 | 159 | static func save(path: URL) throws { 160 | let bookmark = try path.bookmarkData( 161 | options: .withSecurityScope, 162 | includingResourceValuesForKeys: nil, 163 | relativeTo: nil) 164 | UserDefaults.standard.setValue(bookmark, forKey: KEY) 165 | } 166 | 167 | static func clear() { 168 | UserDefaults.standard.removeObject(forKey: KEY) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /remember/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ITSAppUsesNonExemptEncryption 6 | 7 | CFBundleDevelopmentRegion 8 | $(DEVELOPMENT_LANGUAGE) 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIconFile 12 | 13 | CFBundleIdentifier 14 | $(PRODUCT_BUNDLE_IDENTIFIER) 15 | CFBundleInfoDictionaryVersion 16 | 6.0 17 | CFBundleName 18 | $(PRODUCT_NAME) 19 | CFBundlePackageType 20 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 21 | CFBundleShortVersionString 22 | $(MARKETING_VERSION) 23 | CFBundleVersion 24 | $(CURRENT_PROJECT_VERSION) 25 | LSApplicationCategoryType 26 | public.app-category.productivity 27 | LSMinimumSystemVersion 28 | $(MACOSX_DEPLOYMENT_TARGET) 29 | LSUIElement 30 | 31 | NSAppTransportSecurity 32 | 33 | NSExceptionDomains 34 | 35 | local.remember 36 | 37 | NSExceptionAllowsInsecureHTTPLoads 38 | 39 | 40 | 41 | 42 | NSHumanReadableCopyright 43 | Copyright © 2024 CLEARTYPE SRL. All rights reserved. 44 | NSMainStoryboardFile 45 | Main 46 | NSPrincipalClass 47 | NSApplication 48 | NSSupportsAutomaticTermination 49 | 50 | NSSupportsSuddenTermination 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /remember/KeyboardShortcut.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardShortcut.swift 3 | // Remember 4 | // 5 | // Created by Bogdan Popa on 30/12/2019. 6 | // Copyright © 2019-2024 CLEARTYPE SRL. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class KeyboardShortcut { 12 | static func register() { 13 | let defaults = KeyboardShortcutDefaults.load() 14 | 15 | DDHotKeyCenter.shared()?.registerHotKey( 16 | withKeyCode: defaults.keyCode, 17 | modifierFlags: defaults.modifierFlags, 18 | task: { _ in 19 | 20 | if NSApp.isActive { 21 | Notifications.willHideWindow() 22 | } else { 23 | NSApp.activate(ignoringOtherApps: true) 24 | } 25 | }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /remember/KeyboardShortcutDefaults.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardShortcutDefaults.swift 3 | // Remember 4 | // 5 | // Created by Bogdan Popa on 30/12/2019. 6 | // Copyright © 2019-2024 CLEARTYPE SRL. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct KeyboardShortcutDefaults: Codable { 12 | let keyCode: UInt16 13 | let modifierFlags: UInt 14 | 15 | init(keyCode: UInt16, modifierFlags: UInt) { 16 | self.keyCode = keyCode 17 | self.modifierFlags = modifierFlags 18 | } 19 | 20 | init(fromHotKey hk: DDHotKey) { 21 | self.keyCode = hk.keyCode 22 | self.modifierFlags = hk.modifierFlags 23 | } 24 | 25 | static func load() -> KeyboardShortcutDefaults { 26 | if let data = UserDefaults.standard.data(forKey: "keyboardShortcut") { 27 | return (try? JSONDecoder().decode(KeyboardShortcutDefaults.self, from: data)) ?? `default`() 28 | } 29 | 30 | return `default`() 31 | } 32 | 33 | func save() { 34 | if let data = try? JSONEncoder().encode(self) { 35 | UserDefaults.standard.set(data, forKey: "keyboardShortcut") 36 | } 37 | } 38 | 39 | static func `default`() -> KeyboardShortcutDefaults { 40 | KeyboardShortcutDefaults( 41 | keyCode: Keycode.space, 42 | modifierFlags: NSEvent.ModifierFlags.option.rawValue) 43 | } 44 | 45 | static func asString() -> String { 46 | let defaults = load() 47 | return DDStringFromKeyCode(defaults.keyCode, defaults.modifierFlags) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /remember/KeyboardShortcutField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardShortcutField.swift 3 | // Remember 4 | // 5 | // Created by Bogdan Popa on 30/12/2019. 6 | // Copyright © 2019-2024 CLEARTYPE SRL. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | struct KeyboardShortcutField: NSViewRepresentable { 13 | typealias NSViewType = DDHotKeyTextField 14 | 15 | func makeNSView(context: NSViewRepresentableContext) -> NSViewType { 16 | return DDHotKeyTextField() 17 | } 18 | 19 | func updateNSView(_ nsView: NSViewType, context: NSViewRepresentableContext) { 20 | let defaults = KeyboardShortcutDefaults.load() 21 | 22 | nsView.hotKey = DDHotKey( 23 | keyCode: defaults.keyCode, 24 | modifierFlags: defaults.modifierFlags, 25 | task: { _ in }) 26 | nsView.target = NSApplication.shared.delegate 27 | nsView.action = #selector(AppDelegate.didChangeHotKey(_:)) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /remember/Keycode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Keycode.swift 3 | // remember 4 | // 5 | // Created by Bogdan Popa on 27/12/2019. 6 | // Copyright © 2019-2024 CLEARTYPE SRL. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | // Source: https://gist.github.com/swillits/df648e87016772c7f7e5dbed2b345066 12 | struct Keycode { 13 | // Layout-independent Keys 14 | // eg.These key codes are always the same key on all layouts. 15 | static let returnKey : UInt16 = 0x24 16 | static let enter : UInt16 = 0x4C 17 | static let tab : UInt16 = 0x30 18 | static let space : UInt16 = 0x31 19 | static let delete : UInt16 = 0x33 20 | static let escape : UInt16 = 0x35 21 | static let command : UInt16 = 0x37 22 | static let shift : UInt16 = 0x38 23 | static let capsLock : UInt16 = 0x39 24 | static let option : UInt16 = 0x3A 25 | static let control : UInt16 = 0x3B 26 | static let rightShift : UInt16 = 0x3C 27 | static let rightOption : UInt16 = 0x3D 28 | static let rightControl : UInt16 = 0x3E 29 | static let leftArrow : UInt16 = 0x7B 30 | static let rightArrow : UInt16 = 0x7C 31 | static let downArrow : UInt16 = 0x7D 32 | static let upArrow : UInt16 = 0x7E 33 | static let volumeUp : UInt16 = 0x48 34 | static let volumeDown : UInt16 = 0x49 35 | static let mute : UInt16 = 0x4A 36 | static let help : UInt16 = 0x72 37 | static let home : UInt16 = 0x73 38 | static let pageUp : UInt16 = 0x74 39 | static let forwardDelete : UInt16 = 0x75 40 | static let end : UInt16 = 0x77 41 | static let pageDown : UInt16 = 0x79 42 | static let function : UInt16 = 0x3F 43 | static let f1 : UInt16 = 0x7A 44 | static let f2 : UInt16 = 0x78 45 | static let f4 : UInt16 = 0x76 46 | static let f5 : UInt16 = 0x60 47 | static let f6 : UInt16 = 0x61 48 | static let f7 : UInt16 = 0x62 49 | static let f3 : UInt16 = 0x63 50 | static let f8 : UInt16 = 0x64 51 | static let f9 : UInt16 = 0x65 52 | static let f10 : UInt16 = 0x6D 53 | static let f11 : UInt16 = 0x67 54 | static let f12 : UInt16 = 0x6F 55 | static let f13 : UInt16 = 0x69 56 | static let f14 : UInt16 = 0x6B 57 | static let f15 : UInt16 = 0x71 58 | static let f16 : UInt16 = 0x6A 59 | static let f17 : UInt16 = 0x40 60 | static let f18 : UInt16 = 0x4F 61 | static let f19 : UInt16 = 0x50 62 | static let f20 : UInt16 = 0x5A 63 | 64 | // US-ANSI Keyboard Positions 65 | // eg. These key codes are for the physical key (in any keyboard layout) 66 | // at the location of the named key in the US-ANSI layout. 67 | static let a : UInt16 = 0x00 68 | static let b : UInt16 = 0x0B 69 | static let c : UInt16 = 0x08 70 | static let d : UInt16 = 0x02 71 | static let e : UInt16 = 0x0E 72 | static let f : UInt16 = 0x03 73 | static let g : UInt16 = 0x05 74 | static let h : UInt16 = 0x04 75 | static let i : UInt16 = 0x22 76 | static let j : UInt16 = 0x26 77 | static let k : UInt16 = 0x28 78 | static let l : UInt16 = 0x25 79 | static let m : UInt16 = 0x2E 80 | static let n : UInt16 = 0x2D 81 | static let o : UInt16 = 0x1F 82 | static let p : UInt16 = 0x23 83 | static let q : UInt16 = 0x0C 84 | static let r : UInt16 = 0x0F 85 | static let s : UInt16 = 0x01 86 | static let t : UInt16 = 0x11 87 | static let u : UInt16 = 0x20 88 | static let v : UInt16 = 0x09 89 | static let w : UInt16 = 0x0D 90 | static let x : UInt16 = 0x07 91 | static let y : UInt16 = 0x10 92 | static let z : UInt16 = 0x06 93 | 94 | static let zero : UInt16 = 0x1D 95 | static let one : UInt16 = 0x12 96 | static let two : UInt16 = 0x13 97 | static let three : UInt16 = 0x14 98 | static let four : UInt16 = 0x15 99 | static let five : UInt16 = 0x17 100 | static let six : UInt16 = 0x16 101 | static let seven : UInt16 = 0x1A 102 | static let eight : UInt16 = 0x1C 103 | static let nine : UInt16 = 0x19 104 | 105 | static let equals : UInt16 = 0x18 106 | static let minus : UInt16 = 0x1B 107 | static let semicolon : UInt16 = 0x29 108 | static let apostrophe : UInt16 = 0x27 109 | static let comma : UInt16 = 0x2B 110 | static let period : UInt16 = 0x2F 111 | static let forwardSlash : UInt16 = 0x2C 112 | static let backslash : UInt16 = 0x2A 113 | static let grave : UInt16 = 0x32 114 | static let leftBracket : UInt16 = 0x21 115 | static let rightBracket : UInt16 = 0x1E 116 | 117 | static let keypadDecimal : UInt16 = 0x41 118 | static let keypadMultiply : UInt16 = 0x43 119 | static let keypadPlus : UInt16 = 0x45 120 | static let keypadClear : UInt16 = 0x47 121 | static let keypadDivide : UInt16 = 0x4B 122 | static let keypadEnter : UInt16 = 0x4C 123 | static let keypadMinus : UInt16 = 0x4E 124 | static let keypadEquals : UInt16 = 0x51 125 | static let keypad0 : UInt16 = 0x52 126 | static let keypad1 : UInt16 = 0x53 127 | static let keypad2 : UInt16 = 0x54 128 | static let keypad3 : UInt16 = 0x55 129 | static let keypad4 : UInt16 = 0x56 130 | static let keypad5 : UInt16 = 0x57 131 | static let keypad6 : UInt16 = 0x58 132 | static let keypad7 : UInt16 = 0x59 133 | static let keypad8 : UInt16 = 0x5B 134 | static let keypad9 : UInt16 = 0x5C 135 | } 136 | -------------------------------------------------------------------------------- /remember/Notifications.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Notifications.swift 3 | // remember 4 | // 5 | // Created by Bogdan Popa on 27/12/2019. 6 | // Copyright © 2019-2024 CLEARTYPE SRL. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import NoiseSerde 11 | 12 | struct Notifications { 13 | private static func observe(_ name: NSNotification.Name, using handler: @escaping (Notification) -> Void) { 14 | NotificationCenter.default.addObserver(forName: name, object: nil, queue: nil, using: handler) 15 | } 16 | 17 | static func didToggleStatusItem(show: Bool) { 18 | NotificationCenter.default.post( 19 | name: .didToggleStatusItem, 20 | object: show) 21 | } 22 | 23 | static func observeDidToggleStatusItem(withCompletionHandler handler: @escaping (Bool) -> Void) { 24 | observe(.didToggleStatusItem) { notification in 25 | if let show = notification.object as? Bool { 26 | handler(show) 27 | } 28 | } 29 | } 30 | 31 | static func didRequestSync() { 32 | NotificationCenter.default.post( 33 | name: .didRequestSync, 34 | object: nil) 35 | } 36 | 37 | static func observeDidRequestSync(withCompletionHandler handler: @escaping () -> Void) { 38 | observe(.didRequestSync) { notification in 39 | handler() 40 | } 41 | } 42 | 43 | static func willHideWindow() { 44 | NotificationCenter.default.post( 45 | name: .willHideWindow, 46 | object: nil) 47 | } 48 | 49 | static func observeWillHideWindow(withCompletionHandler handler: @escaping () -> Void) { 50 | observe(.willHideWindow) { _ in 51 | handler() 52 | } 53 | } 54 | 55 | static func willArchiveEntry(entryId: UVarint) { 56 | NotificationCenter.default.post( 57 | name: .willArchiveEntry, 58 | object: entryId) 59 | } 60 | 61 | static func observeWillArchiveEntry(withCompletionHandler handler: @escaping (UVarint) -> Void) { 62 | observe(.willArchiveEntry) { notification in 63 | if let id = notification.object as? UVarint { 64 | handler(id) 65 | } 66 | } 67 | } 68 | 69 | static func willSelectEntry(entryId: UVarint) { 70 | NotificationCenter.default.post( 71 | name: .willSelectEntry, 72 | object: entryId) 73 | } 74 | 75 | static func observeWillSelectEntry(withCompletionHandler handler: @escaping (UVarint) -> Void) { 76 | observe(.willSelectEntry) { notification in 77 | if let id = notification.object as? UVarint { 78 | handler(id) 79 | } 80 | } 81 | } 82 | 83 | static func willSnoozeEntry(entryId: UVarint) { 84 | NotificationCenter.default.post( 85 | name: .willSnoozeEntry, 86 | object: entryId) 87 | } 88 | 89 | static func observeWillSnoozeEntry(withCompletionHandler handler: @escaping (UVarint) -> Void) { 90 | observe(.willSnoozeEntry) { notification in 91 | if let id = notification.object as? UVarint { 92 | handler(id) 93 | } 94 | } 95 | } 96 | } 97 | 98 | extension Notification.Name { 99 | static let didToggleStatusItem = Notification.Name("io.defn.remember.didToggleStatusItem") 100 | static let didRequestSync = Notification.Name("io.defn.remember.didRequestSync") 101 | static let willHideWindow = Notification.Name("io.defn.remember.willHideWindow") 102 | static let willArchiveEntry = Notification.Name("io.defn.remember.willArchiveEntry") 103 | static let willSelectEntry = Notification.Name("io.defn.remember.willSelectEntry") 104 | static let willSnoozeEntry = Notification.Name("io.defn.remember.willSnoozeEntry") 105 | } 106 | -------------------------------------------------------------------------------- /remember/Onboarding.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Onboarding.swift 3 | // Remember 4 | // 5 | // Created by Bogdan Popa on 21/01/2020. 6 | // Copyright © 2020-2024 CLEARTYPE SRL. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | class OnboardingManager: NSObject, NSWindowDelegate { 13 | static let shared = OnboardingManager() 14 | 15 | private var window: OnboardingWindow! 16 | 17 | private override init() { 18 | super.init() 19 | 20 | let window = OnboardingWindow( 21 | contentRect: NSRect(x: 0, y: 0, width: 600, height: 600), 22 | styleMask: [.titled], 23 | backing: .buffered, 24 | defer: false) 25 | window.delegate = self 26 | window.title = "Welcome to Remember" 27 | 28 | self.window = window 29 | } 30 | 31 | func show(force: Bool = false) { 32 | if (force || !onboardingWasSeen()) { 33 | self.window.contentView = NSHostingView(rootView: OnboardingView(store: OnboardingStore())) 34 | self.window.center() 35 | self.window.makeKeyAndOrderFront(self) 36 | } 37 | } 38 | 39 | func hide() { 40 | markOnboardingSeen() 41 | window.orderOut(self) 42 | window.contentView = nil 43 | } 44 | 45 | func windowShouldClose(_ sender: NSWindow) -> Bool { 46 | hide() 47 | return false 48 | } 49 | } 50 | 51 | fileprivate enum Step { 52 | case one 53 | case two 54 | } 55 | 56 | fileprivate class OnboardingStore: ObservableObject { 57 | @Published var currentStep = Step.one 58 | 59 | func `continue`() { 60 | switch currentStep { 61 | case .one: 62 | currentStep = .two 63 | case .two: 64 | OnboardingManager.shared.hide() 65 | } 66 | } 67 | } 68 | 69 | fileprivate struct StepFrame: View where Content: View { 70 | @Environment(\.colorScheme) var colorScheme 71 | 72 | private let content: () -> Content 73 | 74 | init(_ content: @escaping () -> Content) { 75 | self.content = content 76 | } 77 | 78 | var body: some View { 79 | VStack { 80 | content() 81 | Spacer() 82 | } 83 | .padding(25) 84 | .frame(width: 600, height: nil, alignment: .top) 85 | .background(colorScheme == .dark ? Color.black : Color.white) 86 | .clipped() 87 | .shadow(radius: 2) 88 | } 89 | } 90 | 91 | fileprivate struct Pill: View where Content: View { 92 | @Environment(\.colorScheme) var colorScheme 93 | 94 | let content: () -> Content 95 | 96 | init(@ViewBuilder content: @escaping () -> Content) { 97 | self.content = content 98 | } 99 | 100 | var body: some View { 101 | VStack { 102 | content() 103 | } 104 | .padding(10) 105 | .background(colorScheme == .dark ? Color.gray.opacity(0.3) : Color.gray.opacity(0.05)) 106 | .cornerRadius(5) 107 | } 108 | } 109 | 110 | fileprivate struct OnboardingStep1: View { 111 | var body: some View { 112 | StepFrame { 113 | VStack { 114 | Text("Welcome to Remember") 115 | .font(.largeTitle) 116 | Text("Stash distractions away for later.") 117 | .font(.subheadline) 118 | .foregroundColor(.secondary) 119 | 120 | Spacer() 121 | 122 | VStack(alignment: .center, spacing: 10) { 123 | Pill { 124 | Text("Remember is a keyboard-driven application. To activate or de-activate it, you can press ") + 125 | Text(KeyboardShortcutDefaults.asString()).bold() + 126 | Text(" at any time.") 127 | } 128 | 129 | Pill { 130 | HStack { 131 | Text("When the application is active, you can add entries like ") + 132 | Text("buy milk").bold() + 133 | Text(" and press ") + 134 | Text("return").bold() + 135 | Text(" to save them.") 136 | 137 | Image("OnboardingStep1-1") 138 | .resizable() 139 | .frame(width: 258, height: 64, alignment: .center) 140 | } 141 | } 142 | 143 | Pill { 144 | HStack { 145 | Image("OnboardingStep1-2") 146 | .resizable() 147 | .frame(width: 258, height: 64, alignment: .center) 148 | Text("Entries can contain modifiers like ") + 149 | Text("+1d").foregroundColor(.accentColor) + 150 | Text(" or ") + 151 | Text("@10am").foregroundColor(.accentColor) + 152 | Text(" that tell Remember when it should remind you about them.") 153 | } 154 | } 155 | 156 | Pill { 157 | HStack { 158 | Text("You can use the arrow keys to navigate through your pending entries and ") + 159 | Text("⌫").bold() + 160 | Text(" to archive any of the ones you're done with.") 161 | 162 | Image("OnboardingStep1-3") 163 | .resizable() 164 | .frame(width: 335, height: 128, alignment: .center) 165 | } 166 | } 167 | } 168 | .padding(.top, 10) 169 | .padding(.bottom, 10) 170 | } 171 | } 172 | } 173 | } 174 | 175 | fileprivate struct OnboardingStep2: View { 176 | var body: some View { 177 | StepFrame { 178 | VStack { 179 | Spacer() 180 | 181 | VStack(alignment: .center, spacing: 10) { 182 | Pill { 183 | OnboardingStep2P1() 184 | } 185 | 186 | Pill { 187 | Text("Press ") + 188 | Text("⌘,").bold() + 189 | Text(" to bring up the Preferences window where you can change the default key binding and make Remember launch at login.") 190 | } 191 | 192 | Pill { 193 | Text("Press ") + 194 | Text("⌘/").bold() + 195 | Text(" to go through this guide again whenever you need a refresher on how Remember works.") 196 | } 197 | 198 | Pill { 199 | ( 200 | Text("Want to learn more? ") + 201 | Text("Read the user manual...") 202 | .foregroundColor(Color.accentColor) 203 | ) 204 | .onTapGesture(perform: { 205 | if let url = Bundle.main.url(forResource: "manual/index", withExtension: "html") { 206 | NSWorkspace.shared.open(url) 207 | } 208 | }) 209 | } 210 | } 211 | 212 | Spacer() 213 | } 214 | } 215 | } 216 | } 217 | 218 | // Extracted because Swift can't typecheck it on Xcode 12.5. 219 | fileprivate struct OnboardingStep2P1: View { 220 | var body: some View { 221 | VStack { 222 | HStack { 223 | text 224 | 225 | Image("OnboardingStep2-1") 226 | .resizable() 227 | .frame(width: 258.5, height: 64, alignment: .center) 228 | } 229 | 230 | Image("OnboardingStep2-2") 231 | .resizable() 232 | .frame(width: 301, height: 128, alignment: .center) 233 | } 234 | } 235 | 236 | var text: some View { 237 | ( 238 | Text("You can create repeating entries by using modifiers like ") + 239 | Text("\\*daily\\*").foregroundColor(.green) + 240 | Text(", ") + 241 | Text("\\*weekly\\*").foregroundColor(.green) + 242 | Text(" or ") + 243 | Text("\\*every two days\\*").foregroundColor(.green) + 244 | Text(". These entries update their due date whenever you archive them.") 245 | ) 246 | .frame(width: nil, height: 88, alignment: .leading) 247 | .lineLimit(nil) 248 | } 249 | } 250 | 251 | fileprivate struct OnboardingView: View { 252 | @ObservedObject var store: OnboardingStore 253 | 254 | var body: some View { 255 | VStack { 256 | if store.currentStep == .one { 257 | OnboardingStep1() 258 | } else if store.currentStep == .two { 259 | OnboardingStep2() 260 | } 261 | 262 | HStack(alignment: .center, spacing: nil) { 263 | Button(action: { 264 | self.store.continue() 265 | }, label: { 266 | Text(store.currentStep == .one ? "Continue" : "Get Started") 267 | .padding(.leading, 20) 268 | .padding(.trailing, 20) 269 | }) 270 | } 271 | .frame(width: nil, height: 48, alignment: .center) 272 | } 273 | .frame(width: 600, height: 600, alignment: .top) 274 | } 275 | } 276 | 277 | fileprivate class OnboardingWindow: NSWindow {} 278 | 279 | fileprivate func onboardingWasSeen() -> Bool { 280 | return UserDefaults.standard.bool(forKey: "onboardingSeen") 281 | } 282 | 283 | fileprivate func markOnboardingSeen() { 284 | UserDefaults.standard.set(true, forKey: "onboardingSeen") 285 | } 286 | -------------------------------------------------------------------------------- /remember/Preferences.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Preferences.swift 3 | // Remember 4 | // 5 | // Created by Bogdan Popa on 30/12/2019. 6 | // Copyright © 2019-2024 CLEARTYPE SRL. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import Foundation 11 | import LaunchAtLogin 12 | import SwiftUI 13 | 14 | class PreferencesManager: NSObject, NSWindowDelegate { 15 | static let shared = PreferencesManager() 16 | 17 | private var window: PreferencesWindow! 18 | private var toolbarDelegate: PreferencesWindowToolbarDelegate! 19 | 20 | private override init() { 21 | super.init() 22 | 23 | let window = PreferencesWindow( 24 | contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), 25 | styleMask: [.closable, .titled], 26 | backing: .buffered, 27 | defer: false) 28 | window.delegate = self 29 | window.title = "General" 30 | 31 | let toolbar = NSToolbar() 32 | toolbarDelegate = PreferencesWindowToolbarDelegate() 33 | toolbar.delegate = toolbarDelegate 34 | toolbar.selectedItemIdentifier = .general 35 | window.toolbar = toolbar 36 | if #available(macOS 11, *) { 37 | window.toolbarStyle = .preference 38 | } 39 | 40 | self.window = window 41 | } 42 | 43 | func show() { 44 | self.window.contentView = NSHostingView(rootView: GeneralPreferencesView()) 45 | self.window.center() 46 | self.window.makeKeyAndOrderFront(self) 47 | } 48 | 49 | func windowShouldClose(_ sender: NSWindow) -> Bool { 50 | KeyboardShortcut.register() 51 | self.window.orderOut(self) 52 | self.window.contentView = nil 53 | return false 54 | } 55 | } 56 | 57 | private struct GeneralPreferencesView : View { 58 | @ObservedObject var store = PreferencesStore() 59 | 60 | let formatter: NumberFormatter = { 61 | let fmt = NumberFormatter() 62 | fmt.minimum = 1 63 | fmt.maximum = 1440 64 | return fmt 65 | }() 66 | 67 | var body: some View { 68 | Form { 69 | Section { 70 | VStack { 71 | Preference("Startup:") { 72 | Toggle("Launch Remember at Login", isOn: $store.launchAtLogin) 73 | } 74 | Preference("Behavior:") { 75 | Toggle("Show Menu Bar Icon", isOn: $store.showStatusIcon) 76 | } 77 | Preference("Snooze minutes:") { 78 | TextField("", value: $store.snoozeMinutes, formatter: formatter) 79 | .frame(width: 300, height: nil, alignment: .leading) 80 | .offset(x: -8, y: 0) 81 | } 82 | Preference("Show Remember:") { 83 | KeyboardShortcutField() 84 | } 85 | .padding([.top, .bottom], 10) 86 | Preference("Sync:") { 87 | VStack(alignment: .leading, spacing: nil) { 88 | if let url = self.store.syncFolder { 89 | Text(url.relativePath) 90 | .foregroundColor(.secondary) 91 | } 92 | 93 | HStack { 94 | Button(action: { 95 | let panel = NSOpenPanel() 96 | panel.prompt = "Set Sync Folder" 97 | panel.allowsMultipleSelection = false 98 | panel.canChooseFiles = false 99 | panel.canChooseDirectories = true 100 | panel.canCreateDirectories = true 101 | 102 | if panel.runModal() == .OK { 103 | self.store.syncFolder = panel.urls[0] 104 | } 105 | }, label: { 106 | Text("Set Sync Folder...") 107 | }) 108 | 109 | if self.store.syncFolder != nil { 110 | Button(action: { 111 | self.store.syncFolder = nil 112 | }, label: { 113 | Text("Stop Syncing") 114 | }) 115 | Button(action: { 116 | Notifications.didRequestSync() 117 | }, label: { 118 | Text("Sync") 119 | }) 120 | } 121 | } 122 | } 123 | } 124 | } 125 | } 126 | } 127 | .padding(15) 128 | } 129 | } 130 | 131 | private class PreferencesStore: NSObject, ObservableObject { 132 | @Published var launchAtLogin = LaunchAtLogin.isEnabled 133 | @Published var showStatusIcon = StatusItemDefaults.shouldShow() 134 | @Published var snoozeMinutes = SnoozeDefaults.get() 135 | @Published var syncFolder = try! FolderSyncDefaults.load() 136 | 137 | private var launchAtLoginCancellable: AnyCancellable? 138 | private var showStatusIconCancellable: AnyCancellable? 139 | private var snoozeMinutesCancelable: AnyCancellable? 140 | private var syncFolderCancellable: AnyCancellable? 141 | 142 | override init() { 143 | super.init() 144 | 145 | launchAtLoginCancellable = $launchAtLogin.sink { 146 | LaunchAtLogin.isEnabled = $0 147 | } 148 | 149 | showStatusIconCancellable = $showStatusIcon.sink { 150 | Notifications.didToggleStatusItem(show: $0) 151 | } 152 | 153 | snoozeMinutesCancelable = $snoozeMinutes.sink { 154 | try! SnoozeDefaults.set($0) 155 | } 156 | 157 | syncFolderCancellable = $syncFolder.sink { 158 | if let path = $0 { 159 | if path.startAccessingSecurityScopedResource() { 160 | defer { 161 | path.stopAccessingSecurityScopedResource() 162 | } 163 | 164 | try! FolderSyncDefaults.save(path: path) 165 | } 166 | } else { 167 | FolderSyncDefaults.clear() 168 | } 169 | } 170 | } 171 | } 172 | 173 | private struct Preference : View { 174 | private let label: String 175 | private let content: Content 176 | 177 | init(_ label: String, @ViewBuilder content: () -> Content) { 178 | self.label = label 179 | self.content = content() 180 | } 181 | 182 | var body: some View { 183 | HStack(alignment: .top, spacing: nil) { 184 | Text(label) 185 | .frame(width: 150, height: nil, alignment: .trailing) 186 | content 187 | } 188 | .frame(width: 450, height: nil, alignment: .leading) 189 | } 190 | } 191 | 192 | private class PreferencesWindow: NSWindow { 193 | 194 | } 195 | 196 | private class PreferencesWindowToolbarDelegate: NSObject, NSToolbarDelegate { 197 | func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { 198 | return [.general] 199 | } 200 | 201 | func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { 202 | return [.general] 203 | } 204 | 205 | func toolbarSelectableItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { 206 | return [.general] 207 | } 208 | 209 | func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { 210 | switch itemIdentifier { 211 | case .general: 212 | let item = NSToolbarItem(itemIdentifier: .general) 213 | item.target = self 214 | item.action = #selector(viewSelected(_:)) 215 | item.isEnabled = true 216 | item.image = NSImage(named: NSImage.preferencesGeneralName) 217 | item.label = "General" 218 | return item 219 | default: 220 | return nil 221 | } 222 | } 223 | 224 | @objc func viewSelected(_ sender: NSToolbarItem) { 225 | } 226 | } 227 | 228 | private extension NSToolbarItem.Identifier { 229 | static let general = NSToolbarItem.Identifier(rawValue: "General") 230 | } 231 | -------------------------------------------------------------------------------- /remember/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /remember/Snooze.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Snooze.swift 3 | // Remember 4 | // 5 | // Created by Bogdan Popa on 10.07.2021. 6 | // Copyright © 2021, 2024 CLEARTYPE SRL. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum SnoozeError: Error { 12 | case invalidMinutes 13 | } 14 | 15 | class SnoozeDefaults { 16 | private static let KEY = "snoozeMinutes" 17 | private static let DEFAULT = 45 18 | 19 | static func get() -> Int { 20 | let minutes = UserDefaults.standard.integer(forKey: KEY) 21 | if minutes == 0 { 22 | return DEFAULT 23 | } 24 | return minutes 25 | } 26 | 27 | static func set(_ minutes: Int) throws { 28 | if (minutes <= 0) { 29 | throw SnoozeError.invalidMinutes 30 | } 31 | UserDefaults.standard.setValue(minutes, forKey: KEY) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /remember/StatusItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatusItem.swift 3 | // Remember 4 | // 5 | // Created by Bogdan Popa on 24/01/2020. 6 | // Copyright © 2020-2024 CLEARTYPE SRL. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class StatusItemDefaults { 12 | private static let KEY = "hideStatusItem" 13 | 14 | static func shouldShow() -> Bool { 15 | return !UserDefaults.standard.bool(forKey: KEY) 16 | } 17 | 18 | static func hide() { 19 | UserDefaults.standard.set(true, forKey: KEY) 20 | } 21 | 22 | static func show() { 23 | UserDefaults.standard.set(false, forKey: KEY) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /remember/Store.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandStore.swift 3 | // remember 4 | // 5 | // Created by Bogdan Popa on 27/12/2019. 6 | // Copyright © 2019-2024 CLEARTYPE SRL. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import NoiseSerde 11 | 12 | class Store: ObservableObject { 13 | @Published var command = "" 14 | @Published var entries = [Entry]() 15 | @Published var entriesVisible = false 16 | @Published var currentEntry: Entry? = nil 17 | @Published var editingEntryWithId: UVarint? = nil 18 | 19 | init() { 20 | self.updatePendingEntries() 21 | try! Backend.shared.installCallback(entriesDidChangeCb: { [weak self] _ in 22 | DispatchQueue.main.async { 23 | self?.updatePendingEntries() 24 | } 25 | }).wait() 26 | try! Backend.shared.markReadyForChanges().wait() 27 | 28 | Notifications.observeWillArchiveEntry { 29 | _ = Backend.shared.archive(entryWithId: $0) 30 | } 31 | 32 | Notifications.observeWillSelectEntry { id in 33 | if let entry = self.entries.first(where: { $0.id == id }) { 34 | RunLoop.main.schedule { 35 | self.currentEntry = entry 36 | self.updatePendingEntries { 37 | self.showEntries() 38 | } 39 | } 40 | } 41 | } 42 | 43 | Notifications.observeWillSnoozeEntry { 44 | _ = Backend.shared.snooze(entryWithId: $0, forMinutes: UVarint(SnoozeDefaults.get())) 45 | } 46 | } 47 | 48 | func clear() { 49 | hideEntries() 50 | if command.isEmpty { 51 | Notifications.willHideWindow() 52 | } 53 | 54 | command = "" 55 | editingEntryWithId = nil 56 | } 57 | 58 | func commit(command: String) { 59 | commit(command: command) { 60 | Notifications.willHideWindow() 61 | } 62 | } 63 | 64 | func commit(command: String, withCompletionHandler handler: @escaping () -> Void) { 65 | if command.isEmpty { 66 | editCurrentEntry() 67 | } else if let id = editingEntryWithId { 68 | Backend.shared.update( 69 | entryWithId: id, 70 | andCommand: command 71 | ).onComplete { [weak self] _ in 72 | self?.clear() 73 | handler() 74 | } 75 | } else { 76 | Backend.shared.commit(command: command).onComplete { [weak self] _ in 77 | self?.clear() 78 | handler() 79 | } 80 | } 81 | } 82 | 83 | func hideEntries() { 84 | entriesVisible = false 85 | } 86 | 87 | func showEntries() { 88 | entriesVisible = true 89 | } 90 | 91 | func updatePendingEntries() { 92 | updatePendingEntries { } 93 | } 94 | 95 | func updatePendingEntries(withCompletionHandler handler: @escaping () -> Void) { 96 | // Ensure that the "cursor" is preserved as much as possible when the entries 97 | // change by keeping track of the current position. 98 | var currentEntryIndex = 0 99 | if let currentEntry = self.currentEntry { 100 | currentEntryIndex = entries.firstIndex(where: { $0.id == currentEntry.id }) ?? 0 101 | } 102 | 103 | Backend.shared.getPendingEntries().onComplete { [weak self] entries in 104 | guard let self else { return } 105 | 106 | self.entries = entries 107 | if entries.isEmpty { 108 | self.currentEntry = nil 109 | } else if self.currentEntry == nil { 110 | self.currentEntry = entries[0] 111 | } else if let currentEntry = self.currentEntry { 112 | if !entries.contains(where: { $0.id == currentEntry.id }) { 113 | self.currentEntry = entries[currentEntryIndex % entries.count] 114 | } 115 | } 116 | 117 | handler() 118 | } 119 | } 120 | 121 | func archiveCurrentEntry() { 122 | if let currentEntry = self.currentEntry { 123 | Backend.shared.archive(entryWithId: currentEntry.id).onComplete { [weak self] in 124 | UserNotificationsManager.shared.dismiss(byEntryId: currentEntry.id) 125 | self?.updatePendingEntries() 126 | } 127 | } 128 | } 129 | 130 | func deleteCurrentEntry() { 131 | if let currentEntry = self.currentEntry { 132 | Backend.shared.delete(entryWithId: currentEntry.id).onComplete { [weak self] in 133 | UserNotificationsManager.shared.dismiss(byEntryId: currentEntry.id) 134 | self?.updatePendingEntries() 135 | } 136 | } 137 | } 138 | 139 | func editCurrentEntry() { 140 | if let currentEntry = self.currentEntry { 141 | command = currentEntry.title 142 | editingEntryWithId = currentEntry.id 143 | } 144 | } 145 | 146 | func stopEditing() { 147 | if editingEntryWithId != nil { 148 | command = "" 149 | editingEntryWithId = nil 150 | } 151 | } 152 | 153 | private func findPreviousEntryIndex() -> Int { 154 | if let currentEntry = self.currentEntry, 155 | let index = entries.firstIndex(where: { $0.id == currentEntry.id }) { 156 | 157 | return (index - 1) < 0 ? entries.count - 1 : index - 1 158 | } 159 | 160 | return 0 161 | } 162 | 163 | private func findNextEntryIndex() -> Int { 164 | if let currentEntry = self.currentEntry, 165 | let index = entries.firstIndex(where: { $0.id == currentEntry.id }) { 166 | 167 | return (index + 1) % entries.count 168 | } 169 | 170 | return 0 171 | } 172 | 173 | func selectPreviousEntry() { 174 | if entries.isEmpty { 175 | entriesVisible = false 176 | } else if !entriesVisible { 177 | entriesVisible = true 178 | } else { 179 | currentEntry = entries[self.findPreviousEntryIndex()] 180 | stopEditing() 181 | } 182 | } 183 | 184 | func selectNextEntry() { 185 | if entries.isEmpty { 186 | entriesVisible = false 187 | } else if !entriesVisible { 188 | entriesVisible = true 189 | } else { 190 | currentEntry = entries[findNextEntryIndex()] 191 | stopEditing() 192 | } 193 | } 194 | 195 | func undo() { 196 | Backend.shared.undo().onComplete { [weak self] in 197 | self?.updatePendingEntries() 198 | } 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /remember/UserNotifications.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserNotifications.swift 3 | // Remember 4 | // 5 | // Created by Bogdan Popa on 28/12/2019. 6 | // Copyright © 2019-2024 CLEARTYPE SRL. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import NoiseSerde 11 | import UserNotifications 12 | import os 13 | 14 | fileprivate let logger = Logger( 15 | subsystem: "io.defn.remember", 16 | category: "UserNotifications" 17 | ) 18 | 19 | enum UserNotificationInfo: String { 20 | case entryId 21 | } 22 | 23 | enum UserNotificationAction: String { 24 | case `default` = "com.apple.UNNotificationDefaultActionIdentifier" 25 | case dismiss = "com.apple.UNNotificationDismissActionIdentifier" 26 | case archive = "io.defn.remember.ArchiveAction" 27 | case snooze = "io.defn.remember.SnoozeAction" 28 | } 29 | 30 | enum UserNotificationCategory: String { 31 | case entry = "io.defn.remember.EntryCategory" 32 | } 33 | 34 | class UserNotificationsManager: NSObject, UNUserNotificationCenterDelegate { 35 | public static let shared = UserNotificationsManager() 36 | 37 | private let queue = DispatchQueue(label: "io.defn.remember.UserNotificationsManager") 38 | private var pending = [UVarint]() 39 | 40 | private override init() { 41 | super.init() 42 | } 43 | 44 | private func addPending(byId id: UVarint, withDeadline deadline: DispatchTime) -> Bool { 45 | queue.sync { 46 | if self.pending.contains(id) { 47 | return false 48 | } 49 | 50 | queue.asyncAfter(deadline: deadline) { 51 | self.pending.removeAll(where: { $0 == id }) 52 | } 53 | 54 | self.pending.append(id) 55 | return true 56 | } 57 | } 58 | 59 | private func removePending(byId id: UVarint) { 60 | queue.sync { 61 | self.pending.removeAll(where: { $0 == id }) 62 | } 63 | } 64 | 65 | func setup() { 66 | let center = UNUserNotificationCenter.current() 67 | center.requestAuthorization(options: [.alert, .badge, .sound], completionHandler: { granted, err in 68 | if !granted { 69 | logger.error("Alert access not granted.") 70 | return 71 | } 72 | 73 | let archiveAction = UNNotificationAction( 74 | identifier: UserNotificationAction.archive.rawValue, 75 | title: "Archive", 76 | options: [.destructive, .authenticationRequired]) 77 | 78 | let snoozeAction = UNNotificationAction( 79 | identifier: UserNotificationAction.snooze.rawValue, 80 | title: "Snooze", 81 | options: [.destructive, .authenticationRequired]) 82 | 83 | let entryCategory = UNNotificationCategory( 84 | identifier: UserNotificationCategory.entry.rawValue, 85 | actions: [archiveAction, snoozeAction], 86 | intentIdentifiers: [], 87 | options: .customDismissAction) 88 | 89 | center.setNotificationCategories([entryCategory]) 90 | center.delegate = self 91 | 92 | try! Backend.shared.installCallback(entriesDueCb: { entries in 93 | for entry in entries { 94 | if !self.addPending(byId: entry.id, withDeadline: .now() + .seconds(15 * 60)) { 95 | logger.debug("Notification for entry \(entry.id) ignored.") 96 | continue 97 | } 98 | 99 | let content = UNMutableNotificationContent() 100 | content.title = "Remember" 101 | content.subtitle = entry.title 102 | content.sound = .default 103 | content.userInfo = [UserNotificationInfo.entryId.rawValue: entry.id] 104 | content.categoryIdentifier = UserNotificationCategory.entry.rawValue 105 | 106 | let request = UNNotificationRequest( 107 | identifier: String(entry.id), 108 | content: content, 109 | trigger: nil) 110 | 111 | center.add(request) { error in 112 | if let err = error { 113 | logger.error("Failed to add notification: \(err)") 114 | } 115 | } 116 | } 117 | }).wait() 118 | try! Backend.shared.startScheduler().wait() 119 | }) 120 | } 121 | 122 | func dismiss(byEntryId id: UVarint) { 123 | let center = UNUserNotificationCenter.current() 124 | center.removeDeliveredNotifications(withIdentifiers: [String(id)]) 125 | self.removePending(byId: id) 126 | } 127 | 128 | func dismissAll() { 129 | let center = UNUserNotificationCenter.current() 130 | center.removeAllDeliveredNotifications() 131 | } 132 | 133 | func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { 134 | return completionHandler([.list]) 135 | } 136 | 137 | func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { 138 | guard let action = UserNotificationAction(rawValue: response.actionIdentifier) else { 139 | return completionHandler() 140 | } 141 | 142 | let userInfo = response.notification.request.content.userInfo 143 | if let id = userInfo[UserNotificationInfo.entryId.rawValue] as? UVarint { 144 | self.removePending(byId: id) 145 | 146 | switch action { 147 | case .archive: 148 | Notifications.willArchiveEntry(entryId: id) 149 | case .dismiss, .snooze: 150 | Notifications.willSnoozeEntry(entryId: id) 151 | case .`default`: 152 | Notifications.willSelectEntry(entryId: id) 153 | } 154 | } 155 | 156 | completionHandler() 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /remember/VisualEffectBackground.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VisualEffectBackground.swift 3 | // remember 4 | // 5 | // Created by Bogdan Popa on 23/12/2019. 6 | // Copyright © 2019-2024 CLEARTYPE SRL. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | struct VisualEffectBackground: NSViewRepresentable { 13 | typealias NSViewType = NSVisualEffectView 14 | 15 | private let blendingMode: NSVisualEffectView.BlendingMode 16 | private let material: NSVisualEffectView.Material 17 | private let state: NSVisualEffectView.State 18 | 19 | fileprivate init( 20 | blendingMode: NSVisualEffectView.BlendingMode, 21 | material: NSVisualEffectView.Material, 22 | state: NSVisualEffectView.State 23 | ) { 24 | self.blendingMode = blendingMode 25 | self.material = material 26 | self.state = state 27 | } 28 | 29 | func makeNSView(context: Context) -> NSViewType { 30 | return NSVisualEffectView() 31 | } 32 | 33 | func updateNSView(_ nsView: NSViewType, context: Context) { 34 | nsView.blendingMode = blendingMode 35 | nsView.material = material 36 | nsView.state = state 37 | } 38 | } 39 | 40 | extension View { 41 | func visualEffect( 42 | blendingMode: NSVisualEffectView.BlendingMode = .behindWindow, 43 | material: NSVisualEffectView.Material = .popover, 44 | state: NSVisualEffectView.State = .active 45 | ) -> some View { 46 | background( 47 | VisualEffectBackground( 48 | blendingMode: blendingMode, 49 | material: material, 50 | state: state 51 | ) 52 | ) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /remember/remember-core.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.inherit 8 | 9 | com.apple.security.cs.allow-jit 10 | 11 | com.apple.security.cs.allow-unsigned-executable-memory 12 | 13 | com.apple.security.cs.disable-executable-page-protection 14 | 15 | com.apple.security.cs.disable-library-validation 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /remember/remember.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.cs.allow-dyld-environment-variables 8 | 9 | com.apple.security.cs.allow-jit 10 | 11 | com.apple.security.cs.allow-unsigned-executable-memory 12 | 13 | com.apple.security.cs.disable-executable-page-protection 14 | 15 | com.apple.security.cs.disable-library-validation 16 | 17 | com.apple.security.files.downloads.read-write 18 | 19 | com.apple.security.files.user-selected.read-write 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /remember/vendor/DDHotKey/DDHotKeyCenter.h: -------------------------------------------------------------------------------- 1 | /* 2 | DDHotKey -- DDHotKeyCenter.h 3 | 4 | Copyright (c) Dave DeLong 5 | 6 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 7 | 8 | The software is provided "as is", without warranty of any kind, including all implied warranties of merchantability and fitness. In no event shall the author(s) or copyright holder(s) be liable for any claim, damages, or other liability, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the software or the use or other dealings in the software. 9 | */ 10 | 11 | #import 12 | 13 | //a convenient typedef for the required signature of a hotkey block callback 14 | typedef void (^DDHotKeyTask)(NSEvent*); 15 | 16 | @interface DDHotKey : NSObject 17 | 18 | // creates a new hotkey but does not register it 19 | + (instancetype)hotKeyWithKeyCode:(unsigned short)keyCode modifierFlags:(NSUInteger)flags task:(DDHotKeyTask)task; 20 | 21 | @property (nonatomic, assign, readonly) id target; 22 | @property (nonatomic, readonly) SEL action; 23 | @property (nonatomic, strong, readonly) id object; 24 | @property (nonatomic, copy, readonly) DDHotKeyTask task; 25 | 26 | @property (nonatomic, readonly) unsigned short keyCode; 27 | @property (nonatomic, readonly) NSUInteger modifierFlags; 28 | 29 | @end 30 | 31 | #pragma mark - 32 | 33 | @interface DDHotKeyCenter : NSObject 34 | 35 | + (instancetype)sharedHotKeyCenter; 36 | 37 | /** 38 | Register a hotkey. 39 | */ 40 | - (DDHotKey *)registerHotKey:(DDHotKey *)hotKey; 41 | 42 | /** 43 | Register a target/action hotkey. 44 | The modifierFlags must be a bitwise OR of NSCommandKeyMask, NSAlternateKeyMask, NSControlKeyMask, or NSShiftKeyMask; 45 | Returns the hotkey registered. If registration failed, returns nil. 46 | */ 47 | - (DDHotKey *)registerHotKeyWithKeyCode:(unsigned short)keyCode modifierFlags:(NSUInteger)flags target:(id)target action:(SEL)action object:(id)object; 48 | 49 | /** 50 | Register a block callback hotkey. 51 | The modifierFlags must be a bitwise OR of NSCommandKeyMask, NSAlternateKeyMask, NSControlKeyMask, or NSShiftKeyMask; 52 | Returns the hotkey registered. If registration failed, returns nil. 53 | */ 54 | - (DDHotKey *)registerHotKeyWithKeyCode:(unsigned short)keyCode modifierFlags:(NSUInteger)flags task:(DDHotKeyTask)task; 55 | 56 | /** 57 | See if a hotkey exists with the specified keycode and modifier flags. 58 | NOTE: this will only check among hotkeys you have explicitly registered with DDHotKeyCenter. This does not check all globally registered hotkeys. 59 | */ 60 | - (BOOL)hasRegisteredHotKeyWithKeyCode:(unsigned short)keyCode modifierFlags:(NSUInteger)flags; 61 | 62 | /** 63 | Unregister a specific hotkey 64 | */ 65 | - (void)unregisterHotKey:(DDHotKey *)hotKey; 66 | 67 | /** 68 | Unregister all hotkeys 69 | */ 70 | - (void)unregisterAllHotKeys; 71 | 72 | /** 73 | Unregister all hotkeys with a specific target 74 | */ 75 | - (void)unregisterHotKeysWithTarget:(id)target; 76 | 77 | /** 78 | Unregister all hotkeys with a specific target and action 79 | */ 80 | - (void)unregisterHotKeysWithTarget:(id)target action:(SEL)action; 81 | 82 | /** 83 | Unregister a hotkey with a specific keycode and modifier flags 84 | */ 85 | - (void)unregisterHotKeyWithKeyCode:(unsigned short)keyCode modifierFlags:(NSUInteger)flags; 86 | 87 | /** 88 | Returns a set of currently registered hotkeys 89 | **/ 90 | - (NSSet *)registeredHotKeys; 91 | 92 | @end 93 | 94 | -------------------------------------------------------------------------------- /remember/vendor/DDHotKey/DDHotKeyTextField.h: -------------------------------------------------------------------------------- 1 | /* 2 | DDHotKey -- DDHotKeyTextField.h 3 | 4 | Copyright (c) Dave DeLong 5 | 6 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 7 | 8 | The software is provided "as is", without warranty of any kind, including all implied warranties of merchantability and fitness. In no event shall the author(s) or copyright holder(s) be liable for any claim, damages, or other liability, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the software or the use or other dealings in the software. 9 | */ 10 | 11 | #import 12 | #import "DDHotKeyCenter.h" 13 | 14 | @interface DDHotKeyTextField : NSTextField 15 | 16 | @property (nonatomic, strong) DDHotKey *hotKey; 17 | 18 | @end 19 | 20 | @interface DDHotKeyTextFieldCell : NSTextFieldCell @end -------------------------------------------------------------------------------- /remember/vendor/DDHotKey/DDHotKeyTextField.m: -------------------------------------------------------------------------------- 1 | /* 2 | DDHotKey -- DDHotKeyTextField.m 3 | 4 | Copyright (c) Dave DeLong 5 | 6 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 7 | 8 | The software is provided "as is", without warranty of any kind, including all implied warranties of merchantability and fitness. In no event shall the author(s) or copyright holder(s) be liable for any claim, damages, or other liability, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the software or the use or other dealings in the software. 9 | */ 10 | 11 | #import 12 | 13 | #import "DDHotKeyTextField.h" 14 | #import "DDHotKeyUtilities.h" 15 | 16 | @interface DDHotKeyTextFieldEditor : NSTextView 17 | 18 | @property (nonatomic, weak) DDHotKeyTextField *hotKeyField; 19 | 20 | @end 21 | 22 | static DDHotKeyTextFieldEditor *DDFieldEditor(void); 23 | static DDHotKeyTextFieldEditor *DDFieldEditor(void) { 24 | static DDHotKeyTextFieldEditor *editor; 25 | static dispatch_once_t onceToken; 26 | dispatch_once(&onceToken, ^{ 27 | editor = [[DDHotKeyTextFieldEditor alloc] initWithFrame:NSMakeRect(0, 0, 100, 32)]; 28 | [editor setFieldEditor:YES]; 29 | }); 30 | return editor; 31 | } 32 | 33 | @implementation DDHotKeyTextFieldCell 34 | 35 | - (NSTextView *)fieldEditorForView:(NSView *)view { 36 | if ([view isKindOfClass:[DDHotKeyTextField class]]) { 37 | DDHotKeyTextFieldEditor *editor = DDFieldEditor(); 38 | editor.insertionPointColor = editor.backgroundColor; 39 | editor.hotKeyField = (DDHotKeyTextField *)view; 40 | return editor; 41 | } 42 | return nil; 43 | } 44 | 45 | @end 46 | 47 | @implementation DDHotKeyTextField 48 | 49 | + (Class)cellClass { 50 | return [DDHotKeyTextFieldCell class]; 51 | } 52 | 53 | - (void)setHotKey:(DDHotKey *)hotKey { 54 | if (_hotKey != hotKey) { 55 | _hotKey = hotKey; 56 | [super setStringValue:[DDStringFromKeyCode(hotKey.keyCode, hotKey.modifierFlags) uppercaseString]]; 57 | } 58 | } 59 | 60 | - (void)setStringValue:(NSString *)aString { 61 | NSLog(@"-[DDHotKeyTextField setStringValue:] is not what you want. Use -[DDHotKeyTextField setHotKey:] instead."); 62 | [super setStringValue:aString]; 63 | } 64 | 65 | - (NSString *)stringValue { 66 | NSLog(@"-[DDHotKeyTextField stringValue] is not what you want. Use -[DDHotKeyTextField hotKey] instead."); 67 | return [super stringValue]; 68 | } 69 | 70 | @end 71 | 72 | @implementation DDHotKeyTextFieldEditor { 73 | BOOL _hasSeenKeyDown; 74 | id _globalMonitor; 75 | DDHotKey *_originalHotKey; 76 | } 77 | 78 | - (void)setHotKeyField:(DDHotKeyTextField *)hotKeyField { 79 | _hotKeyField = hotKeyField; 80 | _originalHotKey = _hotKeyField.hotKey; 81 | } 82 | 83 | - (void)processHotkeyEvent:(NSEvent *)event { 84 | NSUInteger flags = event.modifierFlags; 85 | BOOL hasModifier = (flags & (NSCommandKeyMask | NSAlternateKeyMask | NSControlKeyMask | NSShiftKeyMask | NSFunctionKeyMask)) > 0; 86 | 87 | if (event.type == NSKeyDown) { 88 | _hasSeenKeyDown = YES; 89 | unichar character = [event.charactersIgnoringModifiers characterAtIndex:0]; 90 | 91 | 92 | if (hasModifier == NO && ([[NSCharacterSet newlineCharacterSet] characterIsMember:character] || event.keyCode == kVK_Escape)) { 93 | if (event.keyCode == kVK_Escape) { 94 | self.hotKeyField.hotKey = _originalHotKey; 95 | 96 | NSString *str = DDStringFromKeyCode(_originalHotKey.keyCode, _originalHotKey.modifierFlags); 97 | self.textStorage.mutableString.string = [str uppercaseString]; 98 | } 99 | [self.hotKeyField sendAction:self.hotKeyField.action to:self.hotKeyField.target]; 100 | [self.window makeFirstResponder:nil]; 101 | return; 102 | } 103 | } 104 | 105 | if ((event.type == NSKeyDown || (event.type == NSFlagsChanged && _hasSeenKeyDown == NO)) && hasModifier) { 106 | self.hotKeyField.hotKey = [DDHotKey hotKeyWithKeyCode:event.keyCode modifierFlags:flags task:_originalHotKey.task]; 107 | NSString *str = DDStringFromKeyCode(event.keyCode, flags); 108 | [self.textStorage.mutableString setString:[str uppercaseString]]; 109 | [self.hotKeyField sendAction:self.hotKeyField.action to:self.hotKeyField.target]; 110 | } 111 | } 112 | 113 | - (BOOL)becomeFirstResponder { 114 | BOOL ok = [super becomeFirstResponder]; 115 | if (ok) { 116 | _hasSeenKeyDown = NO; 117 | _globalMonitor = [NSEvent addLocalMonitorForEventsMatchingMask:(NSKeyDownMask | NSFlagsChangedMask) handler:^NSEvent*(NSEvent *event){ 118 | [self processHotkeyEvent:event]; 119 | return nil; 120 | }]; 121 | } 122 | return ok; 123 | } 124 | 125 | - (BOOL)resignFirstResponder { 126 | BOOL ok = [super resignFirstResponder]; 127 | if (ok) { 128 | self.hotKeyField = nil; 129 | if (_globalMonitor) { 130 | [NSEvent removeMonitor:_globalMonitor]; 131 | _globalMonitor = nil; 132 | } 133 | } 134 | 135 | return ok; 136 | } 137 | 138 | @end 139 | -------------------------------------------------------------------------------- /remember/vendor/DDHotKey/DDHotKeyUtilities.h: -------------------------------------------------------------------------------- 1 | /* 2 | DDHotKey -- DDHotKeyUtilities.h 3 | 4 | Copyright (c) Dave DeLong 5 | 6 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 7 | 8 | The software is provided "as is", without warranty of any kind, including all implied warranties of merchantability and fitness. In no event shall the author(s) or copyright holder(s) be liable for any claim, damages, or other liability, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the software or the use or other dealings in the software. 9 | */ 10 | 11 | #import 12 | 13 | extern NSString *DDStringFromKeyCode(unsigned short keyCode, NSUInteger modifiers); 14 | extern UInt32 DDCarbonModifierFlagsFromCocoaModifiers(NSUInteger flags); 15 | -------------------------------------------------------------------------------- /remember/vendor/DDHotKey/DDHotKeyUtilities.m: -------------------------------------------------------------------------------- 1 | /* 2 | DDHotKey -- DDHotKeyUtilities.m 3 | 4 | Copyright (c) Dave DeLong 5 | 6 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 7 | 8 | The software is provided "as is", without warranty of any kind, including all implied warranties of merchantability and fitness. In no event shall the author(s) or copyright holder(s) be liable for any claim, damages, or other liability, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the software or the use or other dealings in the software. 9 | */ 10 | 11 | #import "DDHotKeyUtilities.h" 12 | #import 13 | #import 14 | 15 | static NSDictionary *_DDKeyCodeToCharacterMap(void); 16 | static NSDictionary *_DDKeyCodeToCharacterMap(void) { 17 | static NSDictionary *keyCodeMap = nil; 18 | static dispatch_once_t onceToken; 19 | dispatch_once(&onceToken, ^{ 20 | keyCodeMap = @{ 21 | @(kVK_Return) : @"↩", 22 | @(kVK_Tab) : @"⇥", 23 | @(kVK_Space) : @"⎵", 24 | @(kVK_Delete) : @"⌫", 25 | @(kVK_Escape) : @"⎋", 26 | @(kVK_Command) : @"⌘", 27 | @(kVK_Shift) : @"⇧", 28 | @(kVK_CapsLock) : @"⇪", 29 | @(kVK_Option) : @"⌥", 30 | @(kVK_Control) : @"⌃", 31 | @(kVK_RightShift) : @"⇧", 32 | @(kVK_RightOption) : @"⌥", 33 | @(kVK_RightControl) : @"⌃", 34 | @(kVK_VolumeUp) : @"🔊", 35 | @(kVK_VolumeDown) : @"🔈", 36 | @(kVK_Mute) : @"🔇", 37 | @(kVK_Function) : @"\u2318", 38 | @(kVK_F1) : @"F1", 39 | @(kVK_F2) : @"F2", 40 | @(kVK_F3) : @"F3", 41 | @(kVK_F4) : @"F4", 42 | @(kVK_F5) : @"F5", 43 | @(kVK_F6) : @"F6", 44 | @(kVK_F7) : @"F7", 45 | @(kVK_F8) : @"F8", 46 | @(kVK_F9) : @"F9", 47 | @(kVK_F10) : @"F10", 48 | @(kVK_F11) : @"F11", 49 | @(kVK_F12) : @"F12", 50 | @(kVK_F13) : @"F13", 51 | @(kVK_F14) : @"F14", 52 | @(kVK_F15) : @"F15", 53 | @(kVK_F16) : @"F16", 54 | @(kVK_F17) : @"F17", 55 | @(kVK_F18) : @"F18", 56 | @(kVK_F19) : @"F19", 57 | @(kVK_F20) : @"F20", 58 | // @(kVK_Help) : @"", 59 | @(kVK_ForwardDelete) : @"⌦", 60 | @(kVK_Home) : @"↖", 61 | @(kVK_End) : @"↘", 62 | @(kVK_PageUp) : @"⇞", 63 | @(kVK_PageDown) : @"⇟", 64 | @(kVK_LeftArrow) : @"←", 65 | @(kVK_RightArrow) : @"→", 66 | @(kVK_DownArrow) : @"↓", 67 | @(kVK_UpArrow) : @"↑", 68 | }; 69 | }); 70 | return keyCodeMap; 71 | } 72 | 73 | NSString *DDStringFromKeyCode(unsigned short keyCode, NSUInteger modifiers) { 74 | NSMutableString *final = [NSMutableString stringWithString:@""]; 75 | NSDictionary *characterMap = _DDKeyCodeToCharacterMap(); 76 | 77 | if (modifiers & NSControlKeyMask) { 78 | [final appendString:[characterMap objectForKey:@(kVK_Control)]]; 79 | } 80 | if (modifiers & NSAlternateKeyMask) { 81 | [final appendString:[characterMap objectForKey:@(kVK_Option)]]; 82 | } 83 | if (modifiers & NSShiftKeyMask) { 84 | [final appendString:[characterMap objectForKey:@(kVK_Shift)]]; 85 | } 86 | if (modifiers & NSCommandKeyMask) { 87 | [final appendString:[characterMap objectForKey:@(kVK_Command)]]; 88 | } 89 | 90 | if (keyCode == kVK_Control || keyCode == kVK_Option || keyCode == kVK_Shift || keyCode == kVK_Command) { 91 | return final; 92 | } 93 | 94 | NSString *mapped = [characterMap objectForKey:@(keyCode)]; 95 | if (mapped != nil) { 96 | [final appendString:mapped]; 97 | } else { 98 | 99 | TISInputSourceRef currentKeyboard = TISCopyCurrentKeyboardInputSource(); 100 | CFDataRef uchr = (CFDataRef)TISGetInputSourceProperty(currentKeyboard, kTISPropertyUnicodeKeyLayoutData); 101 | 102 | // Fix crash using non-unicode layouts, such as Chinese or Japanese. 103 | if (!uchr) { 104 | CFRelease(currentKeyboard); 105 | currentKeyboard = TISCopyCurrentASCIICapableKeyboardLayoutInputSource(); 106 | uchr = (CFDataRef)TISGetInputSourceProperty(currentKeyboard, kTISPropertyUnicodeKeyLayoutData); 107 | } 108 | 109 | const UCKeyboardLayout *keyboardLayout = (const UCKeyboardLayout*)CFDataGetBytePtr(uchr); 110 | 111 | if (keyboardLayout) { 112 | UInt32 deadKeyState = 0; 113 | UniCharCount maxStringLength = 255; 114 | UniCharCount actualStringLength = 0; 115 | UniChar unicodeString[maxStringLength]; 116 | 117 | UInt32 keyModifiers = DDCarbonModifierFlagsFromCocoaModifiers(modifiers); 118 | 119 | OSStatus status = UCKeyTranslate(keyboardLayout, 120 | keyCode, kUCKeyActionDown, keyModifiers, 121 | LMGetKbdType(), 0, 122 | &deadKeyState, 123 | maxStringLength, 124 | &actualStringLength, unicodeString); 125 | 126 | if (actualStringLength > 0 && status == noErr) { 127 | NSString *characterString = [NSString stringWithCharacters:unicodeString length:(NSUInteger)actualStringLength]; 128 | 129 | [final appendString:characterString]; 130 | } 131 | } 132 | } 133 | 134 | return final; 135 | } 136 | 137 | UInt32 DDCarbonModifierFlagsFromCocoaModifiers(NSUInteger flags) { 138 | UInt32 newFlags = 0; 139 | if ((flags & NSControlKeyMask) > 0) { newFlags |= controlKey; } 140 | if ((flags & NSCommandKeyMask) > 0) { newFlags |= cmdKey; } 141 | if ((flags & NSShiftKeyMask) > 0) { newFlags |= shiftKey; } 142 | if ((flags & NSAlternateKeyMask) > 0) { newFlags |= optionKey; } 143 | if ((flags & NSAlphaShiftKeyMask) > 0) { newFlags |= alphaLock; } 144 | return newFlags; 145 | } 146 | -------------------------------------------------------------------------------- /remember/vendor/DDHotKey/README.markdown: -------------------------------------------------------------------------------- 1 | # DDHotKey 2 | 3 | Copyright © Dave DeLong 4 | 5 | ## About 6 | 7 | DDHotKey is an easy-to-use Cocoa class for registering an application to respond to system key events, or "hotkeys". 8 | 9 | A global hotkey is a key combination that always executes a specific action, regardless of which app is frontmost. For example, the Mac OS X default hotkey of "command-space" shows the Spotlight search bar, even if Finder is not the frontmost application. 10 | 11 | ## License 12 | 13 | The license for this framework is included in every source file, and is repoduced in its entirety here: 14 | 15 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. The software is provided "as is", without warranty of any kind, including all implied warranties of merchantability and fitness. In no event shall the authors or copyright holders be liable for any claim, damages, or other liability, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the software or the use or other dealings in the software. 16 | 17 | ## Usage 18 | 19 | ### Including DDHotKey in your project 20 | 21 | You will need to copy these six files into your project: 22 | 23 | - DDHotKeyCenter.h 24 | - DDHotKeyCenter.m 25 | - DDHotKeyUtilities.h 26 | - DDHotKeyUtilities.m 27 | - DDHotKeyTextField.h 28 | - DDHotKeyTextField.m 29 | 30 | Your application will need to link against `Carbon.framework`, and you will need to compile your application with the Clang compiler. DDHotKey has been tested with Xcode 5 on OS X Mavericks. No attempt has been made to preserve backwards compatibility. 31 | 32 | ### Using DDHotKey in your code 33 | 34 | When you wish to create a hotkey, you'll need to do so via the `DDHotKeyCenter` singleton. 35 | 36 | You can register a hotkey in one of two ways: via a target/action mechanism, or with a block. The target/action mechanism can take a single extra "object" parameter, which it will pass into the action when the hotkey is fired. Only the `object` parameter is retained by the `DDHotKeyCenter`. In addition, an `NSEvent` object is passed, which contains information regarding the hotkey event (such as the location, the keyCode, the modifierFlags, etc). 37 | 38 | Hotkey actions must have one of two method signatures (the actual selector is irrelevant): 39 | 40 | //a method with a single NSEvent parameter 41 | - (void)hotkeyAction:(NSEvent*)hotKeyEvent; 42 | 43 | OR 44 | 45 | //a method with an NSEvent parameter and an object parameter 46 | - (void)hotkeyAction:(NSEvent*)hotKeyEvent withObject:(id)anObject; 47 | 48 | The other way to register a hotkey is with a block callback. The block must have the following signature: 49 | 50 | void (^)(NSEvent *); 51 | 52 | `DDHotKeyCenter.h` contains a typedef statement to typedef this signature as a `DDHotKeyTask`, for convenience. 53 | 54 | Any hotkey that you have registered via `DDHotKeyCenter` can be unregistered based on its target, its target and action, or its keycode and modifier flags. 55 | 56 | DDHotKey also includes a rudimentary `DDHotKeyTextField`, which is an `NSTextField` subclass that simplifies the process of creating a key combination. Simply drop an `NSTextField` into your xib and change its class to `DDHotKeyTextField`. Programmatically, you'll get an NSTextField into which you can type arbitrary key combinations. You access the resulting combination via the textfield's `hotKey` property. 57 | -------------------------------------------------------------------------------- /tests/info.rkt: -------------------------------------------------------------------------------- 1 | #lang info 2 | 3 | (define collection "tests") 4 | (define deps 5 | '("base" 6 | "db-lib" 7 | "deta-lib" 8 | "gregor-lib" 9 | "rackunit-lib" 10 | "remember" 11 | "threading-lib")) 12 | -------------------------------------------------------------------------------- /tests/remember/command.rkt: -------------------------------------------------------------------------------- 1 | #lang racket/base 2 | 3 | (require gregor 4 | racket/match 5 | rackunit 6 | remember/command) 7 | 8 | (check-equal? 9 | (parse-command "hello") 10 | (list 11 | (make-Token 12 | #:text "hello" 13 | #:span (Span 14 | (Location 1 0 0) 15 | (Location 1 5 5))))) 16 | 17 | (check-equal? 18 | (parse-command "hello +1d there") 19 | (list 20 | (make-Token 21 | #:text "hello " 22 | #:span (Span 23 | (Location 1 0 0) 24 | (Location 1 6 6))) 25 | (make-Token 26 | #:text "+1d" 27 | #:span (Span 28 | (Location 1 6 6) 29 | (Location 1 9 9)) 30 | #:data (TokenData.relative-time 1 'd)) 31 | (make-Token 32 | #:text " there" 33 | #:span (Span 34 | (Location 1 9 9) 35 | (Location 1 15 15))))) 36 | 37 | (check-equal? 38 | (parse-command "hello + there") 39 | (list 40 | (make-Token 41 | #:text "hello " 42 | #:span (Span 43 | (Location 1 0 0) 44 | (Location 1 6 6))) 45 | (make-Token 46 | #:text "+" 47 | #:span (Span 48 | (Location 1 6 6) 49 | (Location 1 7 7))) 50 | (make-Token 51 | #:text " there" 52 | #:span (Span 53 | (Location 1 7 7) 54 | (Location 1 13 13))))) 55 | 56 | (check-equal? 57 | (parse-command "buy milk +1d #groceries") 58 | (list 59 | (make-Token 60 | #:text "buy milk " 61 | #:span (Span 62 | (Location 1 0 0) 63 | (Location 1 9 9))) 64 | (make-Token 65 | #:text "+1d" 66 | #:span (Span 67 | (Location 1 9 9) 68 | (Location 1 12 12)) 69 | #:data (TokenData.relative-time 1 'd)) 70 | (make-Token 71 | #:text " " 72 | #:span (Span 73 | (Location 1 12 12) 74 | (Location 1 13 13))) 75 | (make-Token 76 | #:text "#groceries" 77 | #:span (Span 78 | (Location 1 13 13) 79 | (Location 1 23 23)) 80 | #:data (TokenData.tag "groceries")))) 81 | 82 | (parameterize ([current-clock (lambda () 0)]) 83 | (check-equal? 84 | (parse-command "buy milk @mon #groceries") 85 | (list 86 | (make-Token 87 | #:text "buy milk " 88 | #:span (Span 89 | (Location 1 0 0) 90 | (Location 1 9 9))) 91 | (make-Token 92 | #:text "@mon" 93 | #:span (Span 94 | (Location 1 9 9) 95 | (Location 1 13 13)) 96 | #:data (TokenData.named-date 97 | (date 1970 1 5))) 98 | (make-Token 99 | #:text " " 100 | #:span (Span 101 | (Location 1 13 13) 102 | (Location 1 14 14))) 103 | (make-Token 104 | #:text "#groceries" 105 | #:span (Span 106 | (Location 1 14 14) 107 | (Location 1 24 24)) 108 | #:data (TokenData.tag "groceries")))) 109 | 110 | (check-equal? 111 | (parse-command "buy milk @thu #groceries") 112 | (list 113 | (make-Token 114 | #:text "buy milk " 115 | #:span (Span 116 | (Location 1 0 0) 117 | (Location 1 9 9))) 118 | (make-Token 119 | #:text "@thu" 120 | #:span (Span 121 | (Location 1 9 9) 122 | (Location 1 13 13)) 123 | #:data (TokenData.named-date 124 | (date 1970 1 8))) 125 | (make-Token 126 | #:text " " 127 | #:span (Span 128 | (Location 1 13 13) 129 | (Location 1 14 14))) 130 | (make-Token 131 | #:text "#groceries" 132 | #:span (Span 133 | (Location 1 14 14) 134 | (Location 1 24 24)) 135 | #:data (TokenData.tag "groceries")))) 136 | 137 | (check-equal? 138 | (parse-command "buy milk @3pm #groceries") 139 | (list 140 | (make-Token 141 | #:text "buy milk " 142 | #:span (Span 143 | (Location 1 0 0) 144 | (Location 1 9 9))) 145 | (make-Token 146 | #:text "@3pm " 147 | #:span (Span 148 | (Location 1 9 9) 149 | (Location 1 14 14)) 150 | #:data (TokenData.named-datetime 151 | (datetime 1970 1 1 15))) 152 | (make-Token 153 | #:text "#groceries" 154 | #:span (Span 155 | (Location 1 14 14) 156 | (Location 1 24 24)) 157 | #:data (TokenData.tag "groceries")))) 158 | 159 | (check-equal? 160 | (parse-command "buy milk @3:15pm tmw #groceries") 161 | (list 162 | (make-Token 163 | #:text "buy milk " 164 | #:span (Span 165 | (Location 1 0 0) 166 | (Location 1 9 9))) 167 | (make-Token 168 | #:text "@3:15pm tmw" 169 | #:span (Span 170 | (Location 1 9 9) 171 | (Location 1 20 20)) 172 | #:data (TokenData.named-datetime 173 | (datetime 1970 1 2 15 15))) 174 | (make-Token 175 | #:text " " 176 | #:span (Span 177 | (Location 1 20 20) 178 | (Location 1 21 21))) 179 | (make-Token 180 | #:text "#groceries" 181 | #:span (Span 182 | (Location 1 21 21) 183 | (Location 1 31 31)) 184 | #:data (TokenData.tag "groceries")))) 185 | 186 | (check-equal? 187 | (parse-command "invoice Jim @10am mon *weekly*") 188 | (list 189 | (make-Token 190 | #:text "invoice Jim " 191 | #:span (Span 192 | (Location 1 0 0) 193 | (Location 1 12 12))) 194 | (make-Token 195 | #:text "@10am mon" 196 | #:span (Span 197 | (Location 1 12 12) 198 | (Location 1 21 21)) 199 | #:data (TokenData.named-datetime 200 | (datetime 1970 1 5 10))) 201 | (make-Token 202 | #:text " " 203 | #:span (Span 204 | (Location 1 21 21) 205 | (Location 1 22 22))) 206 | (make-Token 207 | #:text "*weekly*" 208 | #:span (Span 209 | (Location 1 22 22) 210 | (Location 1 30 30)) 211 | #:data (TokenData.recurrence 1 'week)))) 212 | 213 | (check-equal? 214 | (parse-command "invoice Jim @10am mon *every 2 weeks*") 215 | (list 216 | (make-Token 217 | #:text "invoice Jim " 218 | #:span (Span 219 | (Location 1 0 0) 220 | (Location 1 12 12))) 221 | (make-Token 222 | #:text "@10am mon" 223 | #:span (Span 224 | (Location 1 12 12) 225 | (Location 1 21 21)) 226 | #:data (TokenData.named-datetime 227 | (datetime 1970 1 5 10))) 228 | (make-Token 229 | #:text " " 230 | #:span (Span 231 | (Location 1 21 21) 232 | (Location 1 22 22))) 233 | (make-Token 234 | #:text "*every 2 weeks*" 235 | #:span (Span 236 | (Location 1 22 22) 237 | (Location 1 37 37)) 238 | #:data (TokenData.recurrence 2 'week)))) 239 | 240 | (define-check (check-named-datetime command expected) 241 | (match-define (TokenData.named-datetime datetime) 242 | (Token-data (car (parse-command command)))) 243 | (check-equal? datetime expected)) 244 | 245 | (check-named-datetime "@9am" (datetime 1970 1 1 9 0 0 0)) 246 | (check-named-datetime "@09am" (datetime 1970 1 1 9 0 0 0)) 247 | (check-named-datetime "@10pm" (datetime 1970 1 1 22 0 0 0)) 248 | (check-named-datetime "@10:35pm" (datetime 1970 1 1 22 35 0 0)) 249 | (check-named-datetime "@10:35pm tmw" (datetime 1970 1 2 22 35 0 0)) 250 | (check-named-datetime "@09" (datetime 1970 1 1 9 0 0 0)) 251 | (check-named-datetime "@09:59 mon" (datetime 1970 1 5 9 59 0 0)) 252 | (check-named-datetime "@22" (datetime 1970 1 1 22 0 0 0)) 253 | (check-named-datetime "@22:35" (datetime 1970 1 1 22 35 0 0)) 254 | (check-named-datetime "@22:35 tmw" (datetime 1970 1 2 22 35 0 0)) 255 | (check-named-datetime "@25:59 mon" (datetime 1970 1 1 2 0 0 0))) 256 | -------------------------------------------------------------------------------- /tests/remember/ring.rkt: -------------------------------------------------------------------------------- 1 | #lang racket/base 2 | 3 | (require rackunit 4 | remember/ring) 5 | 6 | (define r (make-ring 3)) 7 | (ring-push! r 1) 8 | (ring-push! r 2) 9 | (check-equal? (ring-size r) 2) 10 | (check-eqv? (ring-pop! r) 2) 11 | 12 | (ring-push! r 2) 13 | (ring-push! r 3) 14 | (ring-push! r 4) 15 | (check-equal? (ring-size r) 3) 16 | (check-eqv? (ring-pop! r) 4) 17 | (check-eqv? (ring-pop! r) 3) 18 | (check-eqv? (ring-pop! r) 2) 19 | (check-false (ring-pop! r)) 20 | (check-false (ring-pop! r)) 21 | 22 | (ring-push! r 1) 23 | (check-eqv? (ring-pop! r) 1) 24 | (check-false (ring-pop! r)) 25 | -------------------------------------------------------------------------------- /tests/remember/undo.rkt: -------------------------------------------------------------------------------- 1 | #lang racket/base 2 | 3 | (require rackunit 4 | remember/ring 5 | remember/undo) 6 | 7 | (parameterize ([current-undo-ring (make-ring 128)]) 8 | (define x #f) 9 | 10 | (push-undo! (lambda () (set! x 1))) 11 | (push-undo! (lambda () (set! x 2))) 12 | (push-undo! (lambda () (set! x 3))) 13 | 14 | (undo!) 15 | (check-eqv? x 3) 16 | 17 | (undo!) 18 | (check-eqv? x 2) 19 | 20 | (undo!) 21 | (check-eqv? x 1) 22 | 23 | (undo!) 24 | (check-eqv? x 1)) 25 | -------------------------------------------------------------------------------- /website/.gitattributes: -------------------------------------------------------------------------------- 1 | assets/demo-adding.mp4 filter=lfs diff=lfs merge=lfs -text 2 | assets/demo-listing.mp4 filter=lfs diff=lfs merge=lfs -text 3 | assets/demo-notify.mp4 filter=lfs diff=lfs merge=lfs -text 4 | assets/demo.mp4 filter=lfs diff=lfs merge=lfs -text 5 | -------------------------------------------------------------------------------- /website/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: deploy 2 | deploy: 3 | rsync --exclude Makefile -avh . remember:~/www/ 4 | -------------------------------------------------------------------------------- /website/assets/.gitattributes: -------------------------------------------------------------------------------- 1 | demo-adding.gif filter=lfs diff=lfs merge=lfs -text 2 | demo-listing.gif filter=lfs diff=lfs merge=lfs -text 3 | demo-notify.gif filter=lfs diff=lfs merge=lfs -text 4 | demo.gif filter=lfs diff=lfs merge=lfs -text 5 | -------------------------------------------------------------------------------- /website/assets/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | all: demo.gif demo-adding.gif demo-listing.gif demo-notify.gif 3 | 4 | %.gif: %.mp4 5 | ffmpeg -i $< -vf "fps=15,scale=520:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" -loop 0 $@ 6 | -------------------------------------------------------------------------------- /website/assets/demo-adding.gif: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:66043025d951e8a8ec8797815839446301a4f1ff3a37bfc4e45b937676f04cb3 3 | size 386341 4 | -------------------------------------------------------------------------------- /website/assets/demo-adding.mp4: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:19f33ccbb8a7512eb99143fdd7a63c9bef463edf4b6204c6d52bc3072bd5d549 3 | size 922066 4 | -------------------------------------------------------------------------------- /website/assets/demo-listing.gif: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:ed073c6b44639e5fd801b27ec7a9d3efe49186b85e1ab77854c1a1c6dbfb8d8c 3 | size 446319 4 | -------------------------------------------------------------------------------- /website/assets/demo-listing.mp4: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:4e3202f1a4ea2189366f2d299ebc0af727cf3254fdd6524239d0f02596da96a8 3 | size 789479 4 | -------------------------------------------------------------------------------- /website/assets/demo-notify.gif: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:4ffbc37cbb896693cc23f20565d623dec0373e28833a12f304fdc612087ba9b8 3 | size 880848 4 | -------------------------------------------------------------------------------- /website/assets/demo-notify.mp4: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:af9cfe7b0f6f669bb579ab44bc1a227b61542500ca7b62154460778ac07a056e 3 | size 1782308 4 | -------------------------------------------------------------------------------- /website/assets/demo.gif: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:04a7c42da18cd4948a887850b2fc39921ed689f3434bb8d37becd4a105d2087b 3 | size 2454397 4 | -------------------------------------------------------------------------------- /website/assets/demo.mp4: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:3ac50348c0dc439096d73be87acb2b945f02c553ea550ef6b0c9e17f729a8cfb 3 | size 3985006 4 | -------------------------------------------------------------------------------- /website/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bogdanp/remember/e5749f58776ad7f00a0ecfe5f4db8a5c6d9b5ffd/website/assets/logo.png -------------------------------------------------------------------------------- /website/manual/manual-racket.css: -------------------------------------------------------------------------------- 1 | /* See the beginning of "manual.css". */ 2 | 3 | /* Monospace: */ 4 | 5 | .RktIn, .RktRdr, .RktPn, .RktMeta, 6 | .RktMod, .RktKw, .RktVar, .RktSym, 7 | .RktRes, .RktOut, .RktCmt, .RktVal, 8 | .RktBlk, .RktErr { 9 | font-family: 'Fira-Mono', monospace; 10 | white-space: inherit; 11 | font-size: 1rem; 12 | line-height: 1.5; 13 | 14 | } 15 | 16 | /* this selctor grabs the first linked Racket symbol 17 | in a definition box (i.e., the symbol being defined) */ 18 | a.RktValDef, a.RktStxDef, a.RktSymDef, 19 | span.RktValDef, span.RktStxDef, span.RktSymDef 20 | { 21 | font-size: 1.1rem; 22 | color: black; 23 | font-weight: 500; 24 | } 25 | 26 | 27 | .inheritedlbl { 28 | font-family: 'Fira', sans-serif; 29 | } 30 | 31 | .RBackgroundLabelInner { 32 | font-family: inherit; 33 | } 34 | 35 | /* ---------------------------------------- */ 36 | /* Inherited methods, left margin */ 37 | 38 | .inherited { 39 | width: 95%; 40 | margin-top: 0.5em; 41 | text-align: left; 42 | background-color: inherit; 43 | } 44 | 45 | .inherited td { 46 | font-size: 82%; 47 | padding-left: 0.5rem; 48 | line-height: 1.3; 49 | text-indent: 0; 50 | padding-right: 0; 51 | } 52 | 53 | .inheritedlbl { 54 | font-style: normal; 55 | } 56 | 57 | /* ---------------------------------------- */ 58 | /* Racket text styles */ 59 | 60 | .RktIn { 61 | color: #cc6633; 62 | background-color: #eee; 63 | } 64 | 65 | .RktInBG { 66 | background-color: #eee; 67 | } 68 | 69 | 70 | .refcolumn .RktInBG { 71 | background-color: white; 72 | } 73 | 74 | .RktRdr { 75 | } 76 | 77 | .RktPn { 78 | color: #843c24; 79 | } 80 | 81 | .RktMeta { 82 | color: black; 83 | } 84 | 85 | .RktMod { 86 | color: inherit; 87 | } 88 | 89 | .RktOpt { 90 | color: black; 91 | font-style: italic; 92 | } 93 | 94 | .RktKw { 95 | color: black; 96 | } 97 | 98 | .RktErr { 99 | color: red; 100 | font-style: italic; 101 | font-weight: 400; 102 | } 103 | 104 | .RktVar { 105 | position: relative; 106 | left: -1px; font-style: italic; 107 | color: #444; 108 | } 109 | 110 | .SVInsetFlow .RktVar { 111 | font-weight: 400; 112 | color: #444; 113 | } 114 | 115 | 116 | .RktSym { 117 | color: inherit; 118 | } 119 | 120 | 121 | 122 | .RktValLink, .RktStxLink, .RktModLink { 123 | text-decoration: none; 124 | color: #07A; 125 | font-size: 1rem; 126 | } 127 | 128 | /* for syntax links within headings */ 129 | h2 a.RktStxLink, h3 a.RktStxLink, h4 a.RktStxLink, h5 a.RktStxLink, 130 | h2 a.RktValLink, h3 a.RktValLink, h4 a.RktValLink, h5 a.RktValLink, 131 | h2 .RktSym, h3 .RktSym, h4 .RktSym, h5 .RktSym, 132 | h2 .RktMod, h3 .RktMod, h4 .RktMod, h5 .RktMod, 133 | h2 .RktVal, h3 .RktVal, h4 .RktVal, h5 .RktVal, 134 | h2 .RktPn, h3 .RktPn, h4 .RktPn, h5 .RktPn { 135 | color: #333; 136 | font-size: 1.50rem; 137 | font-weight: 400; 138 | } 139 | 140 | .toptoclink .RktStxLink, .toclink .RktStxLink, 141 | .toptoclink .RktValLink, .toclink .RktValLink, 142 | .toptoclink .RktModLink, .toclink .RktModLink { 143 | color: inherit; 144 | } 145 | 146 | .tocset .RktValLink, .tocset .RktStxLink, .tocset .RktModLink, .tocset .RktSym { 147 | color: black; 148 | font-weight: 400; 149 | font-size: 0.9rem; 150 | } 151 | 152 | .tocset td a.tocviewselflink .RktValLink, 153 | .tocset td a.tocviewselflink .RktStxLink, 154 | .tocset td a.tocviewselflink .RktMod, 155 | .tocset td a.tocviewselflink .RktSym { 156 | font-weight: lighter; 157 | color: white; 158 | } 159 | 160 | 161 | .RktRes { 162 | color: #0000af; 163 | } 164 | 165 | .RktOut { 166 | color: #960096; 167 | } 168 | 169 | .RktCmt { 170 | color: #c2741f; 171 | } 172 | 173 | .RktVal { 174 | color: #228b22; 175 | } 176 | 177 | /* ---------------------------------------- */ 178 | /* Some inline styles */ 179 | 180 | .together { /* for definitions grouped together in one box */ 181 | width: 100%; 182 | border-top: 2px solid white; 183 | } 184 | 185 | tbody > tr:first-child > td > .together { 186 | border-top: 0px; /* erase border on first instance of together */ 187 | } 188 | 189 | .RktBlk { 190 | white-space: pre; 191 | text-align: left; 192 | } 193 | 194 | .highlighted { 195 | font-size: 1rem; 196 | background-color: #fee; 197 | } 198 | 199 | .defmodule { 200 | font-family: 'Fira-Mono', monospace; 201 | padding: 0.25rem 0.75rem 0.25rem 0.5rem; 202 | margin-bottom: 1rem; 203 | width: 100%; 204 | background-color: #ebf0f4; 205 | } 206 | 207 | .defmodule a { 208 | color: #444; 209 | } 210 | 211 | 212 | .defmodule td span.hspace:first-child { 213 | position: absolute; 214 | width: 0; 215 | display: inline-block; 216 | } 217 | 218 | .defmodule .RpackageSpec .Smaller, 219 | .defmodule .RpackageSpec .stt { 220 | font-size: 1rem; 221 | } 222 | 223 | /* make parens ordinary color in defmodule */ 224 | .defmodule .RktPn { 225 | color: inherit; 226 | } 227 | 228 | .specgrammar { 229 | float: none; 230 | padding-left: 1em; 231 | } 232 | 233 | 234 | .RBibliography td { 235 | vertical-align: text-top; 236 | padding-top: 1em; 237 | } 238 | 239 | .leftindent { 240 | margin-left: 2rem; 241 | margin-right: 0em; 242 | } 243 | 244 | .insetpara { 245 | margin-left: 1em; 246 | margin-right: 1em; 247 | } 248 | 249 | .SCodeFlow .Rfilebox { 250 | margin-left: -1em; /* see 17.2 of guide, module languages */ 251 | } 252 | 253 | .Rfiletitle { 254 | text-align: right; 255 | background-color: #eee; 256 | } 257 | 258 | .SCodeFlow .Rfiletitle { 259 | border-top: 1px dotted gray; 260 | border-right: 1px dotted gray; 261 | } 262 | 263 | 264 | .Rfilename { 265 | border-top: 0; 266 | border-right: 0; 267 | padding-left: 0.5em; 268 | padding-right: 0.5em; 269 | background-color: inherit; 270 | } 271 | 272 | .Rfilecontent { 273 | margin: 0.5em; 274 | } 275 | 276 | .RpackageSpec { 277 | padding-right: 0; 278 | } 279 | 280 | /* ---------------------------------------- */ 281 | /* For background labels */ 282 | 283 | .RBackgroundLabel { 284 | float: right; 285 | width: 0px; 286 | height: 0px; 287 | } 288 | 289 | .RBackgroundLabelInner { 290 | position: relative; 291 | width: 25em; 292 | left: -25.5em; 293 | top: 0.20rem; /* sensitive to monospaced font choice */ 294 | text-align: right; 295 | z-index: 0; 296 | font-weight: 300; 297 | font-family: 'Fira-Mono', monospace; 298 | font-size: 0.9rem; 299 | color: gray; 300 | } 301 | 302 | 303 | .RpackageSpec .Smaller { 304 | font-weight: 300; 305 | font-family: 'Fira-Mono', monospace; 306 | font-size: 0.9rem; 307 | } 308 | 309 | .RForeground { 310 | position: relative; 311 | left: 0px; 312 | top: 0px; 313 | z-index: 1; 314 | } 315 | 316 | /* ---------------------------------------- */ 317 | /* For section source modules & tags */ 318 | 319 | .RPartExplain { 320 | background: #eee; 321 | font-size: 0.9rem; 322 | margin-top: 0.2rem; 323 | padding: 0.2rem; 324 | text-align: left; 325 | } 326 | -------------------------------------------------------------------------------- /website/manual/racket.css: -------------------------------------------------------------------------------- 1 | 2 | /* See the beginning of "scribble.css". */ 3 | 4 | /* Monospace: */ 5 | .RktIn, .RktRdr, .RktPn, .RktMeta, 6 | .RktMod, .RktKw, .RktVar, .RktSym, 7 | .RktRes, .RktOut, .RktCmt, .RktVal, 8 | .RktBlk { 9 | font-family: monospace; 10 | white-space: inherit; 11 | } 12 | 13 | /* Serif: */ 14 | .inheritedlbl { 15 | font-family: serif; 16 | } 17 | 18 | /* Sans-serif: */ 19 | .RBackgroundLabelInner { 20 | font-family: sans-serif; 21 | } 22 | 23 | /* ---------------------------------------- */ 24 | /* Inherited methods, left margin */ 25 | 26 | .inherited { 27 | width: 100%; 28 | margin-top: 0.5em; 29 | text-align: left; 30 | background-color: #ECF5F5; 31 | } 32 | 33 | .inherited td { 34 | font-size: 82%; 35 | padding-left: 1em; 36 | text-indent: -0.8em; 37 | padding-right: 0.2em; 38 | } 39 | 40 | .inheritedlbl { 41 | font-style: italic; 42 | } 43 | 44 | /* ---------------------------------------- */ 45 | /* Racket text styles */ 46 | 47 | .RktIn { 48 | color: #cc6633; 49 | background-color: #eeeeee; 50 | } 51 | 52 | .RktInBG { 53 | background-color: #eeeeee; 54 | } 55 | 56 | .RktRdr { 57 | } 58 | 59 | .RktPn { 60 | color: #843c24; 61 | } 62 | 63 | .RktMeta { 64 | color: black; 65 | } 66 | 67 | .RktMod { 68 | color: black; 69 | } 70 | 71 | .RktOpt { 72 | color: black; 73 | font-style: italic; 74 | } 75 | 76 | .RktKw { 77 | color: black; 78 | } 79 | 80 | .RktErr { 81 | color: red; 82 | font-style: italic; 83 | } 84 | 85 | .RktVar { 86 | color: #262680; 87 | font-style: italic; 88 | } 89 | 90 | .RktSym { 91 | color: #262680; 92 | } 93 | 94 | .RktSymDef { /* used with RktSym at def site */ 95 | } 96 | 97 | .RktValLink { 98 | text-decoration: none; 99 | color: blue; 100 | } 101 | 102 | .RktValDef { /* used with RktValLink at def site */ 103 | } 104 | 105 | .RktModLink { 106 | text-decoration: none; 107 | color: blue; 108 | } 109 | 110 | .RktStxLink { 111 | text-decoration: none; 112 | color: black; 113 | } 114 | 115 | .RktStxDef { /* used with RktStxLink at def site */ 116 | } 117 | 118 | .RktRes { 119 | color: #0000af; 120 | } 121 | 122 | .RktOut { 123 | color: #960096; 124 | } 125 | 126 | .RktCmt { 127 | color: #c2741f; 128 | } 129 | 130 | .RktVal { 131 | color: #228b22; 132 | } 133 | 134 | /* ---------------------------------------- */ 135 | /* Some inline styles */ 136 | 137 | .together { 138 | width: 100%; 139 | } 140 | 141 | .prototype, .argcontract, .RBoxed { 142 | white-space: nowrap; 143 | } 144 | 145 | .prototype td { 146 | vertical-align: text-top; 147 | } 148 | 149 | .RktBlk { 150 | white-space: inherit; 151 | text-align: left; 152 | } 153 | 154 | .RktBlk tr { 155 | white-space: inherit; 156 | } 157 | 158 | .RktBlk td { 159 | vertical-align: baseline; 160 | white-space: inherit; 161 | } 162 | 163 | .argcontract td { 164 | vertical-align: text-top; 165 | } 166 | 167 | .highlighted { 168 | background-color: #ddddff; 169 | } 170 | 171 | .defmodule { 172 | width: 100%; 173 | background-color: #F5F5DC; 174 | } 175 | 176 | .specgrammar { 177 | float: right; 178 | } 179 | 180 | .RBibliography td { 181 | vertical-align: text-top; 182 | } 183 | 184 | .leftindent { 185 | margin-left: 1em; 186 | margin-right: 0em; 187 | } 188 | 189 | .insetpara { 190 | margin-left: 1em; 191 | margin-right: 1em; 192 | } 193 | 194 | .Rfilebox { 195 | } 196 | 197 | .Rfiletitle { 198 | text-align: right; 199 | margin: 0em 0em 0em 0em; 200 | } 201 | 202 | .Rfilename { 203 | border-top: 1px solid #6C8585; 204 | border-right: 1px solid #6C8585; 205 | padding-left: 0.5em; 206 | padding-right: 0.5em; 207 | background-color: #ECF5F5; 208 | } 209 | 210 | .Rfilecontent { 211 | margin: 0em 0em 0em 0em; 212 | } 213 | 214 | .RpackageSpec { 215 | padding-right: 0.5em; 216 | } 217 | 218 | /* ---------------------------------------- */ 219 | /* For background labels */ 220 | 221 | .RBackgroundLabel { 222 | float: right; 223 | width: 0px; 224 | height: 0px; 225 | } 226 | 227 | .RBackgroundLabelInner { 228 | position: relative; 229 | width: 25em; 230 | left: -25.5em; 231 | top: 0px; 232 | text-align: right; 233 | color: white; 234 | z-index: 0; 235 | font-weight: bold; 236 | } 237 | 238 | .RForeground { 239 | position: relative; 240 | left: 0px; 241 | top: 0px; 242 | z-index: 1; 243 | } 244 | 245 | /* ---------------------------------------- */ 246 | /* History */ 247 | 248 | .SHistory { 249 | font-size: 82%; 250 | } 251 | -------------------------------------------------------------------------------- /website/manual/scribble-common.js: -------------------------------------------------------------------------------- 1 | // Common functionality for PLT documentation pages 2 | 3 | // Page Parameters ------------------------------------------------------------ 4 | 5 | var page_query_string = location.search.substring(1); 6 | 7 | var page_args = 8 | ((function(){ 9 | if (!page_query_string) return []; 10 | var args = page_query_string.split(/[&;]/); 11 | for (var i=0; i= 0) args[i] = [a.substring(0,p), a.substring(p+1)]; 15 | else args[i] = [a, false]; 16 | } 17 | return args; 18 | })()); 19 | 20 | function GetPageArg(key, def) { 21 | for (var i=0; i= 0 && cur.substring(0,eql) == key) 78 | return unescape(cur.substring(eql+1)); 79 | } 80 | return def; 81 | } 82 | } 83 | 84 | function SetCookie(key, val) { 85 | try { 86 | localStorage[key] = val; 87 | } catch(e) { 88 | var d = new Date(); 89 | d.setTime(d.getTime()+(365*24*60*60*1000)); 90 | try { 91 | document.cookie = 92 | key + "=" + escape(val) + "; expires="+ d.toGMTString() + "; path=/"; 93 | } catch (e) {} 94 | } 95 | } 96 | 97 | // note that this always stores a directory name, ending with a "/" 98 | function SetPLTRoot(ver, relative) { 99 | var root = location.protocol + "//" + location.host 100 | + NormalizePath(location.pathname.replace(/[^\/]*$/, relative)); 101 | SetCookie("PLT_Root."+ver, root); 102 | } 103 | 104 | // adding index.html works because of the above 105 | function GotoPLTRoot(ver, relative) { 106 | var u = GetCookie("PLT_Root."+ver, null); 107 | if (u == null) return true; // no cookie: use plain up link 108 | // the relative path is optional, default goes to the toplevel start page 109 | if (!relative) relative = "index.html"; 110 | location = u + relative; 111 | return false; 112 | } 113 | 114 | // Utilities ------------------------------------------------------------------ 115 | 116 | var normalize_rxs = [/\/\/+/g, /\/\.(\/|$)/, /\/[^\/]*\/\.\.(\/|$)/]; 117 | function NormalizePath(path) { 118 | var tmp, i; 119 | for (i = 0; i < normalize_rxs.length; i++) 120 | while ((tmp = path.replace(normalize_rxs[i], "/")) != path) path = tmp; 121 | return path; 122 | } 123 | 124 | // `noscript' is problematic in some browsers (always renders as a 125 | // block), use this hack instead (does not always work!) 126 | // document.write(""); 127 | 128 | // Interactions --------------------------------------------------------------- 129 | 130 | function DoSearchKey(event, field, ver, top_path) { 131 | var val = field.value; 132 | if (event && event.key === 'Enter') { 133 | var u = GetCookie("PLT_Root."+ver, null); 134 | if (u == null) u = top_path; // default: go to the top path 135 | u += "search/index.html?q=" + encodeURIComponent(val); 136 | u = MergePageArgsIntoUrl(u); 137 | location = u; 138 | return false; 139 | } 140 | return true; 141 | } 142 | 143 | function TocviewToggle(glyph, id) { 144 | var s = document.getElementById(id).style; 145 | var expand = s.display == "none"; 146 | s.display = expand ? "block" : "none"; 147 | glyph.innerHTML = expand ? "▼" : "►"; 148 | } 149 | 150 | function TocsetToggle() { 151 | document.body.classList.toggle("tocsetoverlay"); 152 | } 153 | 154 | // Page Init ------------------------------------------------------------------ 155 | 156 | // Note: could make a function that inspects and uses window.onload to chain to 157 | // a previous one, but this file needs to be required first anyway, since it 158 | // contains utilities for all other files. 159 | var on_load_funcs = []; 160 | function AddOnLoad(fun) { on_load_funcs.push(fun); } 161 | window.onload = function() { 162 | for (var i=0; i