├── .gitignore ├── LICENSE ├── README.md ├── indent_notes.rb ├── sample.taskpaper ├── src ├── css │ ├── fallback.css │ └── tweaks.css ├── taskpaper+html.rb ├── taskpaperdocument.rb ├── taskpaperemoticonsexportplugin.rb ├── taskpaperentityencodingexportplugin.rb ├── taskpaperexportplugin.rb ├── taskpaperexportpluginmanager.rb ├── taskpaperitem.rb ├── taskpapermarkdownexportplugin.rb ├── taskpapertagiconsexportplugin.rb └── taskpaperthemeconverter.rb ├── taskpaper-api-demo.rb ├── taskpaper-to-html.rb └── template.html /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/examples.txt 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | ## Specific to RubyMotion: 14 | .dat* 15 | .repl_history 16 | build/ 17 | 18 | ## Documentation cache and generated files: 19 | /.yardoc/ 20 | /_yardoc/ 21 | /doc/ 22 | /rdoc/ 23 | 24 | ## Environment normalization: 25 | /.bundle/ 26 | /vendor/bundle 27 | /lib/bundler/man/ 28 | 29 | # for a library or gem, you might want to ignore these files since the code is 30 | # intended to run in multiple environments; otherwise, check them in: 31 | # Gemfile.lock 32 | # .ruby-version 33 | # .ruby-gemset 34 | 35 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 36 | .rvmrc 37 | .DS_Store 38 | 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Matt Gemmell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TaskPaperRuby 2 | 3 | by [Matt Gemmell](http://mattgemmell.com/) 4 | 5 | 6 | ## What is it? 7 | 8 | It's a Ruby script that lets you create and edit [TaskPaper](http://www.taskpaper.com)-format files. It can also export TaskPaper files to HTML and CSS, with [Less](http://lesscss.org)-based auto-conversion of your TaskPaper 3 theme. 9 | 10 | 11 | ## What are its requirements? 12 | 13 | - Ruby 14 | - [optionally] [Less CSS](http://lesscss.org) 15 | 16 | To install Less on Mac OS X, the simplest way is: 17 | 18 | 1. Install [Homebrew](http://brew.sh) (instructions on that page). 19 | 2. Use Homebrew to install [npm](https://nodejs.org/): `brew install npm` in Terminal. 20 | 3. Use npm to install Less: `npm install -g less` in Terminal. 21 | 22 | Now you can use Less via `lessc` in Terminal. If you don't install Less, this script will use a default theme instead. 23 | 24 | 25 | ## What does it do? 26 | 27 | It does a few things: 28 | 29 | 1. Lets you open, edit, save, and create TaskPaper files. 30 | 2. Exports TaskPaper files to HTML and CSS. 31 | 3. Auto-converts your own [TaskPaper theme](http://guide.taskpaper.com/creating_themes.html) to use with the HTML. 32 | 33 | Here's what it makes of [my TaskPaper theme](http://mattgemmell.com/taskpaper-3/). This is the original file in TaskPaper: 34 | 35 | ![TaskPaper file](https://c2.staticflickr.com/2/1570/25426422743_ac5c3be362_c.jpg) 36 | 37 | And here's the resulting HTML file: 38 | 39 | ![HTML export](https://c2.staticflickr.com/2/1473/25962552091_95623b3731_c.jpg) 40 | 41 | You'll probably want to tweak the resulting CSS, but this should at least get you started. 42 | 43 | It also includes a series of plugins to modify the HTML (or text) output. For example, it'll render basic span-level [Markdown](https://en.wikipedia.org/wiki/Markdown) (like _emphasis_ / **emphasis** and `backticked code`), transform a few text emoticons into emoji, and substitute a few tag names/values with icons. You can enable or disable plugins in the `taskpaper-to-html.rb` file. 44 | 45 | 46 | ## How do I use it? 47 | 48 | ###To convert a given TaskPaper file to HTML 49 | 50 | Just run this command, with appropriate values: 51 | 52 | `ruby taskpaper-to-html.rb ~/Documents/sample.taskpaper ~/Desktop/output.html` 53 | 54 | You can also add an optional third parameter, giving the path to an HTML template file. See the included `template.html` file for what it should look like. 55 | 56 | **Note:** This expects that your TaskPaper theme is in the normal place, and that TaskPaper itself is in the usual Applications folder. If that's not the case, you'll want to tweak `taskpaperthemeconverter.rb` first. 57 | 58 | ###To edit a TaskPaper file via Ruby 59 | 60 | doc = TaskPaperDocument.new("~/Desktop/new.taskpaper") 61 | doc.add_child(TaskPaperItem.new("Inbox:")) 62 | item = TaskPaperItem.new("- Do thing @today") 63 | doc.items[0].add_child(item) 64 | puts doc.to_text 65 | doc.save_file 66 | 67 | See the `TaskPaperDocument` and `TaskPaperItem` classes for more. You can: 68 | 69 | - Manipulate an item's `done` status with `done?`, `set_done`, and `toggle_done` 70 | - Manipulate tags with `tag_value`, `has_tag?`, `set_tag`, and `remove_tag` 71 | - Retrieve or modify content as a whole with `content` 72 | - Inspect and modify types and relationships with `parent`, `children`, `add_child`, `type`, `type_name`, and `inspect` 73 | - Extract metadata with `tags` and `links` 74 | - Inspect position in the hierarchy with `level` (hierarchical), and `effective_level` (taking account of any extra levels of indentation) 75 | - Produce hierarchical representations with `to_text`, `to_tags`, `to_links`, and `to_structure` 76 | 77 | And probably more. 78 | 79 | 80 | 81 | ## Who made it? 82 | 83 | Matt Gemmell (that's me). 84 | 85 | - My website is at [mattgemmell.com](http://mattgemmell.com) 86 | 87 | - I'm on Twitter as [@mattgemmell](http://twitter.com/mattgemmell) 88 | 89 | - This code is on github at [github.com/mattgemmell/TaskPaperRuby](http://github.com/mattgemmell/TaskPaperRuby) 90 | 91 | 92 | ## What license is the code released under? 93 | 94 | The [MIT license](http://choosealicense.com/licenses/mit/). 95 | 96 | If you need a different license, feel free to ask. I'm flexible about this. 97 | 98 | 99 | ## Why did you make it? 100 | 101 | Mostly for fun. 102 | 103 | 104 | ## Can you provide support? 105 | 106 | Nope. If you find a bug, please fix it and submit a pull request via github. 107 | 108 | 109 | ## I have a feature request 110 | 111 | Feel free to [create an issue](https://github.com/mattgemmell/TaskPaperRuby/issues) with your idea. 112 | 113 | 114 | ## How can I thank you? 115 | 116 | You can: 117 | 118 | - [Support my writing](http://mattgemmell.com/support-me/). 119 | 120 | - Check out [my Amazon wishlist](http://www.amazon.co.uk/registry/wishlist/1BGIQ6Z8GT06F). 121 | 122 | - Follow me [on Twitter](http://twitter.com/mattgemmell). 123 | -------------------------------------------------------------------------------- /indent_notes.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | 3 | =begin 4 | 5 | This script visually indents all notes by one level, without affecting their children. This is useful for OmniFocus text exports, which put notes at the same level as the item they pertain to, whereas I prefer to have notes subordinate to their corresponding item. 6 | 7 | =end 8 | 9 | tp_ruby_dir = File.expand_path("~/Dropbox/TaskPaper/TaskPaperRuby/src") 10 | file = File.expand_path("~/Desktop/test.taskpaper") 11 | 12 | require_relative File.join(tp_ruby_dir, 'taskpaperdocument') 13 | 14 | doc = TaskPaperDocument.new(file) 15 | 16 | notes = doc.all_notes 17 | notes.each do |note| 18 | # Make note a child of its previous sibling 19 | sibling = note.previous_sibling 20 | 21 | if sibling 22 | note.parent.remove_child(note) 23 | sibling.add_child(note) 24 | 25 | # Maintain note's children's apparent positions 26 | children = note.remove_all_children 27 | sibling.add_children(children) 28 | end 29 | end 30 | 31 | doc.save_file 32 | -------------------------------------------------------------------------------- /sample.taskpaper: -------------------------------------------------------------------------------- 1 | One project: 2 | - Here's a task, converted by `ruby` :) 3 | - Here's a _special_ task with a **link**: http://twitter.com/mattgemmell 4 | - An example task goes here @my-tag @your-tag 5 | - Call the person about the thing @today 6 | Sub-project: 7 | - This is an important thing @flag 8 | This is a note with a link http://mattgemmell.com/ @my-tag 9 | - A thing I should do @due(2016-03-29 2pm) 10 | Another project: 11 | - Release some source code @done 12 | - Email matt@mattgemmell.com or visit his site at mattgemmell.com 13 | - Work on that thing @context(work) 14 | - Get some exercise @context(outside) 15 | Priorities: 16 | - This is very important @priority(high) 17 | - So is this @priority(1) 18 | - This is of moderate importance @priority(normal) 19 | - This isn't very important at all @priority(low) 20 | - Nor is this @priority(5) 21 | [Searches]: 22 | - Next Actions @search(project *//\(\(not @done\) and \(not @search\)\)[0]) 23 | - Due Today @search(not @done and \(@today or @due <=[d] today +1d or @due <=[d] today\)) 24 | - Due within 7 days @search(not @done and \(@today or @due <=[d] today +7d or @due <=[d] today\)) 25 | - Priority: High @search(not @done and \(@priority = [i]high or @priority = 1\)) 26 | - Flagged @search(not @done and @flag) 27 | - Context: Work @search(not @done and @context = [i]work) -------------------------------------------------------------------------------- /src/css/fallback.css: -------------------------------------------------------------------------------- 1 | body { 2 | color: #333333; 3 | ui-scale: 1; 4 | font-size: 16; 5 | font-family: "Source Sans Pro", Helvetica, Verdana, sans-serif; 6 | background-color: white; 7 | line-height: 1.1; 8 | } 9 | li[data-type="note"] { 10 | font-style: italic; 11 | } 12 | li[data-type="task"] { 13 | font-style: normal; 14 | } 15 | li[data-type="project"] { 16 | font-weight: bold; 17 | line-height: 1.5; 18 | } 19 | li[data-done] > span[content] { 20 | text-decoration: line-through; 21 | } 22 | span[link] { 23 | cursor: pointer; 24 | color: #fb3332; 25 | } 26 | span[link^="button"] { 27 | color: #333333; 28 | } 29 | span[link^="filter"] { 30 | color: #333333; 31 | } 32 | span[tag] { 33 | font-size: 16; 34 | font-style: normal; 35 | font-weight: normal; 36 | color: #b8b8b8; 37 | } 38 | li[data-type=task], 39 | li[data-type=note] { 40 | color: #333333; 41 | } 42 | span[link] a { 43 | text-decoration: none; 44 | } 45 | body { 46 | margin: 0; 47 | padding: 0; 48 | } 49 | a, 50 | a:active, 51 | a:visited { 52 | color: #fb3332; 53 | } 54 | ul, 55 | ol { 56 | margin: 0 auto 0 auto; 57 | } 58 | li { 59 | font-weight: normal; 60 | list-style-type: disc; 61 | margin: 0; 62 | } 63 | li::marker { 64 | color: none; 65 | } 66 | .extra-indent { 67 | margin: 0; 68 | padding: 0; 69 | list-style-type: none; 70 | } 71 | ul, 72 | ul.extra-indent { 73 | padding-left: 20; 74 | } 75 | #taskpaper-document .taskpaper-root { 76 | margin-left: 5px; 77 | } 78 | .task-prefix { 79 | color: #888; 80 | } 81 | .sidebar a { 82 | text-decoration: none; 83 | color: #555; 84 | } 85 | .sidebar [data-type=project] { 86 | font-size: 16px; 87 | font-weight: normal; 88 | } 89 | .sidebar, 90 | .sidebar ul { 91 | padding-left: 10px; 92 | } 93 | .sidebar li { 94 | list-style-type: none; 95 | } 96 | #taskpaper-sidebar, 97 | #taskpaper-document { 98 | display: inline-block; 99 | margin: 0; 100 | padding: 10px 0; 101 | } 102 | #taskpaper-sidebar { 103 | position: fixed; 104 | width: 200px; 105 | height: 100%; 106 | overflow: scroll; 107 | background-color: #f5f5f5; 108 | } 109 | #taskpaper-document { 110 | margin-left: 200px; 111 | padding-bottom: 20px; 112 | } 113 | ::selection { 114 | background: #fec2c2; 115 | } 116 | -------------------------------------------------------------------------------- /src/css/tweaks.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | 6 | a, a:active, a:visited { 7 | color: $LINK_COLOR; 8 | } 9 | 10 | ul, ol { 11 | margin: 0 auto 0 auto; 12 | } 13 | 14 | code { 15 | background-color: #eee; 16 | padding: 0 5px; 17 | } 18 | 19 | li { 20 | font-weight: normal; 21 | list-style-type: disc; 22 | margin: 0; 23 | } 24 | 25 | li::marker { 26 | color: $HANDLE_COLOR; 27 | } 28 | 29 | .extra-indent { 30 | margin: 0; 31 | padding: 0; 32 | list-style-type: none; 33 | } 34 | 35 | ul, ul.extra-indent { 36 | padding-left: $ITEM_INDENT; 37 | } 38 | 39 | #taskpaper-document .taskpaper-root { 40 | margin-left: 5px; 41 | } 42 | 43 | .task-prefix { 44 | color: #888; 45 | } 46 | 47 | .sidebar a { 48 | text-decoration: none; 49 | color: #555; 50 | } 51 | 52 | .sidebar [data-type=project] { 53 | font-size: 16px; 54 | font-weight: normal; 55 | } 56 | 57 | .sidebar, .sidebar ul { 58 | padding-left: 10px; 59 | } 60 | 61 | .sidebar li { 62 | list-style-type: none; 63 | } 64 | 65 | #taskpaper-sidebar, #taskpaper-document { 66 | display: inline-block; 67 | margin: 0; 68 | padding: 10px 0; 69 | } 70 | 71 | #taskpaper-sidebar { 72 | position: fixed; 73 | width: 200px; 74 | height: 100%; 75 | overflow: auto; 76 | overflow-x: hidden; 77 | overflow-y: auto; 78 | background-color: #f5f5f5; 79 | } 80 | 81 | #taskpaper-document { 82 | margin-left: 200px; 83 | padding-bottom: 20px; 84 | } 85 | 86 | #taskpaper-document > ul > li:first-child { 87 | margin-top: 0; 88 | } 89 | 90 | [tag=data-search][tagvalue] { 91 | display: none; 92 | } 93 | 94 | [tag=data-search][tagname] + [content]:after { 95 | content: "…"; 96 | } 97 | 98 | [tag=data-search][tagname]:hover + [content] + [tagvalue] { 99 | display: inline; 100 | } 101 | 102 | [tag=data-search][tagname]:hover + [content]:after { 103 | content: ""; 104 | } 105 | -------------------------------------------------------------------------------- /src/taskpaper+html.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | 3 | # HTML export extensions 4 | 5 | require_relative 'taskpaperdocument' 6 | class TaskPaperDocument 7 | def to_html(only_type = nil, sidebar_mode = false) 8 | return (@root_item) ? @root_item.to_html(only_type, sidebar_mode) : "" 9 | end 10 | 11 | def to_sidebar 12 | return to_html(TaskPaperItem::TYPE_PROJECT, true) 13 | end 14 | end 15 | 16 | require_relative 'taskpaperitem' 17 | class TaskPaperItem 18 | def to_html(only_type = nil, sidebar_mode = false) 19 | # HTML output, CSS-ready 20 | 21 | # If 'only_type' is specified, only that type of item will be output. 22 | # Types are found in TaskPaperItem: TYPE_TASK, etc. 23 | 24 | # If sidebar_mode is true, items will generate sidebar-suitable HTML instead. 25 | # This is only really of interest to projects (TYPE_PROJECT). 26 | 27 | =begin 28 | The TaskPaperItem#to_html method takes account of any discrepancy in an item's nested depth in the graph and its actual indentation in the source file (via @extra_indent), generating an appropriate number of nested UL/LI tags so that the final HTML results accurately reflect the original indentation of each line (assuming suitable CSS is provided, e.g. to apply a margin-left to each UL). 29 | =end 30 | 31 | # Output own content, then children 32 | output = "" 33 | if @type != TYPE_NULL 34 | @extra_indent.times do output += "
  • " end 140 | end 141 | if @type == TYPE_NULL 142 | if sidebar_mode 143 | output = "" 144 | else 145 | output = "" 146 | end 147 | end 148 | return output 149 | end 150 | 151 | def to_sidebar 152 | return to_html(TaskPaperItem::TYPE_PROJECT, true) 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /src/taskpaperdocument.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | 3 | class TaskPaperDocument 4 | require_relative 'taskpaperitem' 5 | 6 | attr_accessor :file_path 7 | 8 | def self.open(path) 9 | return self.new(path) 10 | end 11 | 12 | def initialize(file_path = nil) 13 | @file_path = file_path 14 | 15 | @root_item = nil 16 | load_file 17 | end 18 | 19 | def load_file(path = nil) 20 | # Load TaskPaper file 21 | @root_item = TaskPaperItem.new(nil) 22 | raw_content = [] 23 | 24 | if path != nil 25 | @file_path = path 26 | end 27 | 28 | if @file_path and @file_path != "" 29 | @file_path = File.expand_path(file_path) 30 | end 31 | if !@file_path or !File.exist?(@file_path) 32 | return 33 | end 34 | 35 | File.open(@file_path, 'r') do |this_file| 36 | while line = this_file.gets 37 | raw_content.push line 38 | end 39 | end 40 | 41 | # Create representation of the file's contents 42 | current_item = @root_item 43 | indentation_level = -1 44 | level = 0 45 | 46 | raw_content.each do |line| 47 | # Handle placement in graph, and hierarchical relationships. 48 | =begin 49 | Note: We treat nested items as direct descendants regardless of difference in indentation level, which we consider to be a display matter. For example: 50 | 51 | Item1 52 | |-Item2 53 | |-----Item3 54 | |---Item4 55 | 56 | The above situation is uniquely possible in TaskPaper, unlike in strictly tree-based outliners like OmniOutliner or Scrivener etc, because in TaskPaper any line can be independently indented to any level. This script considers both Item3 and Item4 to be children of Item2 (and siblings of each other), regardless of the indentation difference. This seems logical, and reflects how TaskPaper's folding behaves. 57 | 58 | Each TaskPaperItem object has a #level method giving its nested depth in the graph, and also an @extra_indent instance variable to account for any discrepancy between its level and the actual tab-indentation of its line in the original document. 59 | =end 60 | # Parse leading whitespace for level and @extra_indent 61 | content_start = TaskPaperItem.leading_indentation_length(line) 62 | if content_start > 0 63 | level = TaskPaperItem.leading_indentation_levels(line[0..content_start]) 64 | else 65 | level = 0 66 | end 67 | 68 | new_item = TaskPaperItem.new(line[content_start..-1]) 69 | 70 | indent_delta = level - indentation_level 71 | if indent_delta > 0 72 | # Add as child. 73 | current_item.add_child(new_item) 74 | #puts "Adding #{new_item.type_name} as child" 75 | elsif indent_delta < 0 76 | # Return back up tree to find suitable parent. 77 | # Note: As detailed above, parent may be at _any_ lesser indentation level. 78 | ancestor = current_item 79 | ancestry = 0 80 | while ancestor.level >= level and ancestor.parent != nil 81 | ancestor = ancestor.parent 82 | ancestry += 1 83 | end 84 | ancestor.add_child(new_item) 85 | #puts "Adding #{new_item.type_name} as child of ancestor #{ancestry}" 86 | else 87 | # Add as sibling (i.e. child of current parent). 88 | current_item.parent.add_child(new_item) 89 | #puts "Adding #{new_item.type_name} as sibling" 90 | end 91 | 92 | if indent_delta > 1 93 | new_item.extra_indent = (indent_delta - 1) 94 | end 95 | 96 | indentation_level = level 97 | current_item = new_item 98 | end 99 | end 100 | 101 | def save_file(path = @file_path) 102 | if path and path != "" 103 | File.open(File.expand_path(path), 'w') do |outfile| 104 | outfile.print content 105 | end 106 | else 107 | puts "No path specified to save the file to." 108 | end 109 | end 110 | 111 | def items 112 | return children 113 | end 114 | 115 | def items_flat(only_type = TaskPaperItem::TYPE_ANY, pre_order = true) 116 | return children_flat(only_type, pre_order) 117 | end 118 | 119 | def children 120 | return (@root_item) ? @root_item.children : nil 121 | end 122 | 123 | def children_flat(only_type = TaskPaperItem::TYPE_ANY, pre_order = true) 124 | return (@root_item) ? @root_item.children_flat(only_type, pre_order) : nil 125 | end 126 | 127 | def all_projects 128 | return children_flat(TaskPaperItem::TYPE_PROJECT) 129 | end 130 | 131 | def all_tasks 132 | return children_flat(TaskPaperItem::TYPE_TASK) 133 | end 134 | 135 | def all_notes 136 | return children_flat(TaskPaperItem::TYPE_NOTE) 137 | end 138 | 139 | def all_items 140 | return children_flat 141 | end 142 | 143 | def all_tasks_not_done 144 | return all_tasks.select { |item| !(item.done?) } 145 | end 146 | 147 | def due_today 148 | return all_tasks_not_done.select { |item| 149 | if item.has_tag?("today") 150 | true 151 | elsif item.has_tag?("due") 152 | due = item.tag_value("due") 153 | if due != "" 154 | require 'Date' 155 | due_date = Date.parse(due) 156 | tomorrow = Date.today.next 157 | (tomorrow <=> due_date) > 0 158 | end 159 | else 160 | false 161 | end 162 | } 163 | end 164 | 165 | def all_items_with_tag(tag, value = nil) 166 | return all_items.select { |item| 167 | if tag == nil 168 | false 169 | else 170 | if value != nil 171 | (item.has_tag?(tag) and item.tag_value(tag) == value) 172 | else 173 | item.has_tag?(tag) 174 | end 175 | end 176 | } 177 | end 178 | 179 | def all_tags(with_values = true, prefixed = false) 180 | return (@root_item) ? @root_item.all_tags(with_values, prefixed).uniq.sort : nil 181 | end 182 | 183 | def total_tag_values(tag_name, always_update = false) 184 | return (@root_item) ? @root_item.total_tag_values(tag_name, always_update) : nil 185 | end 186 | 187 | def add_child(child) 188 | return @root_item.add_child(child) 189 | end 190 | 191 | def insert_child(child, index) 192 | return @root_item.insert_child(child, index) 193 | end 194 | 195 | def add_children(children) 196 | return @root_item.add_children(children) 197 | end 198 | 199 | def remove_child(index) 200 | return @root_item.remove_child(index) 201 | end 202 | 203 | def remove_children(range) 204 | return @root_item.remove_children(range) 205 | end 206 | 207 | def remove_all_children 208 | return @root_item.remove_all_children 209 | end 210 | 211 | def content 212 | return to_text.gsub!(/[\r\n]+\Z/, '') 213 | end 214 | 215 | def to_s 216 | return to_text 217 | end 218 | 219 | def to_text 220 | return (@root_item) ? @root_item.to_text : "" 221 | end 222 | 223 | def to_json 224 | return (@root_item) ? @root_item.to_json : "" 225 | end 226 | 227 | def to_tags 228 | return (@root_item) ? @root_item.to_tags : "" 229 | end 230 | 231 | def to_links 232 | return (@root_item) ? @root_item.to_links : "" 233 | end 234 | 235 | def to_structure 236 | return (@root_item) ? @root_item.to_structure : "" 237 | end 238 | end 239 | -------------------------------------------------------------------------------- /src/taskpaperemoticonsexportplugin.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | 3 | class TaskPaperEmoticonsExportPlugin < TaskPaperExportPlugin 4 | 5 | @emoticons = [ 6 | {pattern: /:\-?\)/, replacement: '😃', class_name: 'smile'}, 7 | {pattern: /:\-?\(/, replacement: '🙁', class_name: 'sadface'}, 8 | {pattern: /;\-?\)/, replacement: '😉', class_name: 'wink'}, 9 | {pattern: /:\-?\//, replacement: '😕', class_name: 'confused'}, 10 | ] 11 | 12 | class << self 13 | attr_accessor :emoticons 14 | end 15 | 16 | def process_text(item, run_text, output_type, before_conversion = true, options = {}) 17 | if output_type == OUTPUT_TYPE_HTML 18 | 19 | TaskPaperEmoticonsExportPlugin.emoticons.each do |emoticon| 20 | matches = run_text.to_enum(:scan, emoticon[:pattern]).map { Regexp.last_match } 21 | matches.reverse.each do |match| 22 | text = match[0] 23 | range = Range.new(match.begin(0), match.end(0), true) 24 | run_text[range] = "#{emoticon[:replacement]}" 25 | end 26 | end 27 | 28 | end 29 | 30 | return run_text 31 | end 32 | 33 | end 34 | -------------------------------------------------------------------------------- /src/taskpaperentityencodingexportplugin.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | 3 | class TaskPaperEntityEncodingExportPlugin < TaskPaperExportPlugin 4 | 5 | def process_text(item, run_text, output_type, before_conversion = true, options = {}) 6 | if output_type == OUTPUT_TYPE_HTML 7 | run_text.gsub!('<', '<') 8 | run_text.gsub!('>', '>') 9 | run_text.gsub!(/&(?!amp;)/, '&') 10 | end 11 | 12 | return run_text 13 | end 14 | 15 | end 16 | -------------------------------------------------------------------------------- /src/taskpaperexportplugin.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | 3 | class TaskPaperExportPlugin 4 | OUTPUT_TYPE_TEXT = 0 # textual output, such as a TaskPaper-format file 5 | OUTPUT_TYPE_HTML = 1 # HTML output 6 | OUTPUT_TYPE_JSON = 2 # JSON output ( http://json.org ) 7 | 8 | RUN_TYPE_TEXT = 0 # any plain-text run within an item 9 | RUN_TYPE_TAG_NAME = 1 10 | RUN_TYPE_TAG_VALUE = 2 11 | RUN_TYPE_LINK = 3 12 | 13 | def process_run(item, run_text, run_type, output_type, before_conversion = true, options = {}) 14 | return run_text 15 | end 16 | 17 | def process_text(item, run_text, output_type, before_conversion = true, options = {}) 18 | return process_run(item, run_text, RUN_TYPE_TEXT, output_type, before_conversion, options) 19 | end 20 | 21 | def process_link(item, run_text, output_type, before_conversion = true, options = {}) 22 | return process_run(item, run_text, RUN_TYPE_LINK, output_type, before_conversion, options) 23 | end 24 | 25 | def process_tag_name(item, run_text, output_type, before_conversion = true, options = {}) 26 | return process_run(item, run_text, RUN_TYPE_TAG_NAME, output_type, before_conversion, options) 27 | end 28 | 29 | def process_tag_value(item, run_text, output_type, before_conversion = true, options = {}) 30 | return process_run(item, run_text, RUN_TYPE_TAG_VALUE, output_type, before_conversion, options) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /src/taskpaperexportpluginmanager.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | 3 | require_relative 'taskpaperexportplugin' 4 | class TaskPaperExportPluginManager 5 | @@plugins = [] 6 | @plugins_enabled = true 7 | 8 | class << self 9 | attr_reader :plugins 10 | attr_accessor :plugins_enabled 11 | end 12 | 13 | def self.add_plugin(plugin) 14 | if plugin.is_a?(TaskPaperExportPlugin) 15 | if !(@@plugins.include?(plugin)) 16 | @@plugins.push(plugin) 17 | end 18 | end 19 | end 20 | 21 | def self.remove_plugin(plugin) 22 | if @@plugins.include?(plugin) 23 | @@plugins.delete(plugin) 24 | end 25 | end 26 | 27 | def self.process_text(item, run_text, output_type, before_conversion = true, options = {}) 28 | output = run_text 29 | if TaskPaperExportPluginManager.plugins_enabled 30 | @@plugins.each do |plugin| 31 | output = plugin.process_text(item, run_text, output_type, before_conversion, options) 32 | end 33 | end 34 | return output 35 | end 36 | 37 | def self.process_link(item, run_text, output_type, before_conversion = true, options = {}) 38 | output = run_text 39 | if TaskPaperExportPluginManager.plugins_enabled 40 | @@plugins.each do |plugin| 41 | output = plugin.process_link(item, run_text, output_type, before_conversion, options) 42 | end 43 | end 44 | return output 45 | end 46 | 47 | def self.process_tag_name(item, run_text, output_type, before_conversion = true, options = {}) 48 | output = run_text 49 | if TaskPaperExportPluginManager.plugins_enabled 50 | @@plugins.each do |plugin| 51 | output = plugin.process_tag_name(item, run_text, output_type, before_conversion, options) 52 | end 53 | end 54 | return output 55 | end 56 | 57 | def self.process_tag_value(item, run_text, output_type, before_conversion = true, options = {}) 58 | output = run_text 59 | if TaskPaperExportPluginManager.plugins_enabled 60 | @@plugins.each do |plugin| 61 | output = plugin.process_tag_value(item, run_text, output_type, before_conversion, options) 62 | end 63 | end 64 | return output 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /src/taskpaperitem.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | 3 | require_relative 'taskpaperexportpluginmanager' 4 | class TaskPaperItem 5 | TYPE_ANY = 0 6 | TYPE_NULL = 1 7 | TYPE_TASK = 2 8 | TYPE_PROJECT = 3 9 | TYPE_NOTE = 4 10 | 11 | LINEBREAK_UNIX = "\n" # Unix, Linux, and Mac OS X 12 | LINEBREAK_MAC = "\r" # classic Mac OS 9 and older 13 | LINEBREAK_WINDOWS = "\r\n" # DOS and Windows 14 | 15 | @linebreak = LINEBREAK_UNIX 16 | @tab_size = 4 # Number of spaces used per indentation level (if tabs aren't used) 17 | @convert_atx_headings = false # ie. Markdown "## Headings" to "Projects:" 18 | 19 | class << self 20 | attr_accessor :linebreak, :tab_size, :convert_atx_headings 21 | end 22 | 23 | # If you want to inspect and debug these, may I suggest https://regex101.com ? 24 | @@tab_regexp = /^(?:\t|\ {#{TaskPaperItem.tab_size}})+/io 25 | @@project_regexp = /^(?>\s*)(?>[^-].*?:)(\s*@\S+)*\s*$/i 26 | @@atx_headings_regexp = /^(\s*?)\#+\s*([^:]+?)$/i 27 | @@tag_regexp = /\B@((?>[a-zA-Z0-9\.\-_]+))(?:\((.*?(? 0 80 | @extra_indent += TaskPaperItem.leading_indentation_levels(@content[0..content_start]) 81 | @content = @content[content_start, @content.length] 82 | end 83 | 84 | # Markdown-style ATX headings conversion 85 | if TaskPaperItem.convert_atx_headings 86 | heading_match = @@atx_headings_regexp.match(@content) 87 | if heading_match 88 | @content.gsub!(heading_match[0], "#{heading_match[1]}#{heading_match[2]}:") 89 | end 90 | end 91 | 92 | # Type of item 93 | if @content.start_with?("- ", "* ") 94 | @type = TYPE_TASK 95 | elsif @@project_regexp =~ @content 96 | @type = TYPE_PROJECT 97 | else 98 | @type = TYPE_NOTE 99 | end 100 | 101 | # Tags 102 | @tags = [] 103 | tag_matches = @content.to_enum(:scan, @@tag_regexp).map { Regexp.last_match } 104 | tag_matches.each do |match| 105 | name = match[1] 106 | value = "" 107 | if match[2] 108 | value = match[2] 109 | end 110 | range = Range.new(match.begin(0), match.end(0), true) 111 | @tags.push({name: name, value: value, range: range, type: "tag"}) 112 | end 113 | 114 | # Links 115 | @links = [] 116 | link_matches = @content.to_enum(:scan, @@link_regexp).map { Regexp.last_match } 117 | link_matches.each do |match| 118 | text = match[0] 119 | if match[1] != nil #uri 120 | url = text 121 | elsif match[2] != nil # email 122 | url = "mailto:#{text}" 123 | else # domain 124 | url = "http://#{text}" 125 | end 126 | range = Range.new(match.begin(0), match.end(0), true) 127 | @links.push({text: text, url: url, range: range, type: "link"}) 128 | end 129 | end 130 | private :parse 131 | 132 | def content=(value) 133 | @content = (value) ? value : "" 134 | parse 135 | end 136 | 137 | def level 138 | # Depth in hierarchy, regardless of extra indentation 139 | if @type == TYPE_NULL and !@parent 140 | return -1 141 | end 142 | 143 | level = 0 144 | ancestor = @parent 145 | while ancestor != nil and not (ancestor.type == TYPE_NULL and ancestor.parent == nil) 146 | level += 1 147 | ancestor = ancestor.parent 148 | end 149 | 150 | return level 151 | end 152 | 153 | def effective_level 154 | # Actual (visual) indentation level 155 | if !@parent and @type != TYPE_NULL 156 | return @extra_indent 157 | end 158 | 159 | parent_indent = -2 # nominal parent of root (-1) item 160 | if @parent 161 | parent_indent = @parent.effective_level 162 | end 163 | return parent_indent + 1 + @extra_indent 164 | end 165 | 166 | def project 167 | # Returns closest ancestor project 168 | project = nil 169 | ancestor = @parent 170 | while ancestor and ancestor.type != TYPE_NULL 171 | if ancestor.type == TYPE_PROJECT 172 | project = ancestor 173 | break 174 | else 175 | ancestor = ancestor.parent 176 | end 177 | end 178 | return project 179 | end 180 | 181 | def children_flat(only_type = TaskPaperItem::TYPE_ANY, pre_order = true) 182 | # Recursively return a flat array of items, optionally filtered by type 183 | # (This is a depth-first traversal; set pre_order to false for post-order) 184 | 185 | result = [] 186 | @children.each do |child| 187 | result = result.concat(child.children_flat(only_type, pre_order)) 188 | end 189 | 190 | if @type != TYPE_NULL and only_type and (only_type == TYPE_ANY or only_type == @type) 191 | if pre_order 192 | result = result.unshift(self) 193 | else 194 | result = result.push(self) 195 | end 196 | end 197 | 198 | return result 199 | end 200 | 201 | def add_child(child) 202 | return insert_child(child, -1) 203 | end 204 | 205 | def insert_child(child, index) 206 | if index <= @children.length 207 | if child.is_a?(String) 208 | child = TaskPaperItem.new(child) 209 | end 210 | @children.insert(index, child) # /facepalm 211 | child.parent = self 212 | return child 213 | end 214 | return nil 215 | end 216 | 217 | def add_children(children) 218 | result = [] 219 | children.each do |child| 220 | result.push(insert_child(child, -1)) 221 | end 222 | return result 223 | end 224 | 225 | def remove_child(index) 226 | if index.is_a?(TaskPaperItem) 227 | if @children.include?(index) 228 | index = @children.index(index) 229 | else 230 | return index 231 | end 232 | end 233 | if index < @children.length 234 | child = @children[index] 235 | child.parent = nil 236 | @children.delete_at(index) 237 | return child 238 | end 239 | end 240 | 241 | def remove_children(range) 242 | if range.is_a?(Integer) 243 | range = range..range 244 | end 245 | removed = [] 246 | (range.last).downto(range.first) { |index| 247 | if index < @children.length 248 | child = @children[index] 249 | removed.push(child) 250 | child.parent = nil 251 | @children.delete_at(index) 252 | end 253 | } 254 | return removed 255 | end 256 | 257 | def remove_all_children 258 | return remove_children(0..(@children.length - 1)) 259 | end 260 | 261 | def previous_sibling 262 | sibling = nil 263 | if @parent and @parent.type != TYPE_NULL 264 | siblings = @parent.children 265 | if siblings.length > 1 266 | my_index = siblings.index(self) 267 | if my_index > 0 268 | return siblings[my_index - 1] 269 | end 270 | end 271 | end 272 | return sibling 273 | end 274 | 275 | def next_sibling 276 | sibling = nil 277 | if @parent and @parent.type != TYPE_NULL 278 | siblings = @parent.children 279 | if siblings.length > 1 280 | my_index = siblings.index(self) 281 | if my_index < siblings.length - 1 282 | return siblings[my_index + 1] 283 | end 284 | end 285 | end 286 | return sibling 287 | end 288 | 289 | def title 290 | if @type == TYPE_PROJECT 291 | return @content[0..@content.rindex(':') - 1] 292 | elsif @type == TYPE_TASK 293 | return @content[2..-1].gsub(@@tags_rstrip_regexp, '') 294 | else 295 | return @content 296 | end 297 | end 298 | 299 | def md5_hash 300 | require 'digest/md5' 301 | return Digest::MD5.hexdigest(@content) 302 | end 303 | 304 | def id_attr 305 | id = title 306 | 307 | metadata.each do |x| 308 | if x[:type] == "tag" 309 | val_str = (x[:value] != "") ? "(#{x[:value]})" : "" 310 | id = id.gsub("#{x[:name]}#{val_str}", '') 311 | elsif x[:type] == "link" 312 | id = id.gsub("#{x[:text]}", '') 313 | end 314 | end 315 | 316 | id = id.strip.downcase.gsub(/(&|&)/, ' and ').gsub(/[\s\.\/\\]/, '-').gsub(/[^\w-]/, '').gsub(/[-_]{2,}/, '-').gsub(/^[-_]/, '').gsub(/[-_]$/, '') 317 | 318 | if id == "" 319 | # No content left after stripping tags, links, and special characters. 320 | # We'll use an MD5 hash of the full line. 321 | id = md5_hash 322 | end 323 | 324 | return id 325 | end 326 | 327 | def metadata 328 | # Return unified array of tags and links, ordered by position in line 329 | metadata = @tags + @links 330 | return metadata.sort_by { |e| e[:range].begin } 331 | end 332 | 333 | def type_name 334 | if @type == TYPE_PROJECT 335 | return "Project" 336 | elsif @type == TYPE_TASK 337 | return "Task" 338 | elsif @type == TYPE_NOTE 339 | return "Note" 340 | else 341 | return "Null" 342 | end 343 | end 344 | 345 | def to_s 346 | return @content 347 | end 348 | 349 | def inspect 350 | output = "[#{(self.effective_level)}] #{self.type_name}: #{self.title}" 351 | if @tags.length > 0 352 | output += " tags: #{@tags}" 353 | end 354 | if @links.length > 0 355 | output += " links: #{@links}" 356 | end 357 | if @children.length > 0 358 | output += " [#{@children.length} child#{(@children.length == 1) ? "" : "ren"}]" 359 | end 360 | if self.done? 361 | output += " [DONE]" 362 | end 363 | return output 364 | end 365 | 366 | def change_to(new_type) 367 | # Takes a type constant, e.g. TYPE_TASK etc. 368 | if (@type != TYPE_NULL and @type != TYPE_ANY and 369 | new_type != TYPE_NULL and new_type != TYPE_ANY and 370 | @type != new_type) 371 | 372 | # Use note as our base type 373 | if @type == TYPE_TASK 374 | # Strip task prefix 375 | @content = @content[2..-1] 376 | 377 | elsif @type == TYPE_PROJECT 378 | # Strip rightmost colon 379 | rightmost_colon_index = @content.rindex(":") 380 | if rightmost_colon_index != nil 381 | @content[rightmost_colon_index, 1] = "" 382 | end 383 | end 384 | 385 | if new_type == TYPE_TASK 386 | # Add task prefix 387 | @content = "- #{@content}" 388 | 389 | elsif new_type == TYPE_PROJECT 390 | # Add colon 391 | insertion_index = -1 392 | match = @content.match(@@tags_rstrip_regexp) 393 | if match 394 | insertion_index = match.begin(0) 395 | else 396 | last_non_whitespace_char_index = @content.rindex(/\S/i) 397 | if last_non_whitespace_char_index != nil 398 | insertion_index = last_non_whitespace_char_index + 1 399 | end 400 | end 401 | @content[insertion_index, 0] = ":" 402 | end 403 | 404 | parse 405 | end 406 | end 407 | 408 | def tag_value(name) 409 | # Returns value of tag 'name', or empty string if either the tag exists but has no value, or the tag doesn't exist at all. 410 | 411 | value = "" 412 | tag = @tags.find {|x| x[:name].downcase == name} 413 | if tag 414 | value = tag[:value] 415 | end 416 | 417 | return value 418 | end 419 | 420 | def has_tag?(name) 421 | return (@tags.find {|x| x[:name].downcase == name} != nil) 422 | end 423 | 424 | def done? 425 | return has_tag?("done") 426 | end 427 | 428 | def is_done? 429 | return done? 430 | end 431 | 432 | def set_done(val = true) 433 | is_done = done? 434 | if val == true and !is_done 435 | set_tag("done") 436 | elsif val == false and is_done 437 | remove_tag("done") 438 | end 439 | end 440 | 441 | def toggle_done 442 | set_done(!(done?)) 443 | end 444 | 445 | def tag_string(name, value = "") 446 | val = (value != "") ? "(#{value})" : "" 447 | return "@#{name}#{val}" 448 | end 449 | 450 | def set_tag(name, value = "", force_new = false) 451 | # If tag doesn't already exist, add it at the end of content. 452 | # If tag does exist, replace its range with new form of the tag via tag_string. 453 | value = (value != nil) ? value : "" 454 | new_tag = tag_string(name, value) 455 | if has_tag?(name) and !force_new 456 | tag = @tags.find {|x| x[:name].downcase == name} 457 | @content[tag[:range]] = new_tag 458 | else 459 | @content += " #{new_tag}" 460 | end 461 | parse 462 | end 463 | 464 | def add_tag(name, value = "", force_new = true) 465 | # This method, unlike set_tag_, defaults to adding a new tag even if a tag of the same name already exists. 466 | set_tag(name, value, force_new) 467 | end 468 | 469 | def remove_tag(name) 470 | if has_tag?(name) 471 | # Use range(s), in reverse order. 472 | @tags.reverse.each do |tag| 473 | if tag[:name] == name 474 | strip_tag(tag) 475 | end 476 | end 477 | parse 478 | end 479 | end 480 | 481 | def remove_all_tags 482 | @tags.reverse.each do |tag| 483 | strip_tag(tag) 484 | end 485 | parse 486 | end 487 | 488 | def strip_tag(tag) # private method 489 | # Takes a tag hash, and removes the tag from @content 490 | # Does not perform a #parse, but we should do so afterwards. 491 | # If calling multiple times before a #parse, do so in reverse order in @content. 492 | 493 | range = tag[:range] 494 | whitespace_regexp = /\s/i 495 | content_len = @content.length 496 | tag_start = range.begin 497 | tag_end = range.end 498 | whitespace_before = (tag_start > 0 and (whitespace_regexp =~ @content[tag_start - 1]) != nil) 499 | whitespace_after = (tag_end < content_len - 1 and (whitespace_regexp =~ @content[tag_end]) != nil) 500 | if whitespace_before and whitespace_after 501 | # If tag has whitespace before and after, also remove the whitespace before. 502 | range = Range.new(tag_start - 1, tag_end, true) 503 | elsif tag_start == 0 and whitespace_after 504 | # If tag is at start of line and has whitespace after, also remove the whitespace after. 505 | range = Range.new(tag_start, tag_end + 1, true) 506 | elsif tag_end == content_len - 1 and whitespace_before 507 | # If tag is at end of line and has whitespace before, also remove the whitespace before. 508 | range = Range.new(tag_start - 1, tag_end, true) 509 | end 510 | @content[range] = "" 511 | end 512 | private :strip_tag 513 | 514 | def total_tag_values(tag_name, always_update = false) 515 | # Returns recursive total of numerical values of the given tag. 516 | # If tag is present, its value will be updated according to recursive total of its descendants. If always_update is true, tag value will be set even if it wasn't present. 517 | # Leaf elements without the relevant tag are counted as zero. 518 | # Tag values on branch elements are ignored, and overwritten with their descendants' recursive total. 519 | total = 0 520 | if tag_name and tag_name != "" 521 | has_tag = has_tag?(tag_name) 522 | if @type != TYPE_NULL and @children.length == 0 523 | if has_tag 524 | val_match = /\d+/i.match(tag_value(tag_name)) 525 | if val_match 526 | val = val_match[0].to_i 527 | total += val 528 | end 529 | end 530 | end 531 | 532 | @children.each do |child| 533 | total += child.total_tag_values(tag_name, always_update) 534 | end 535 | 536 | if @type != TYPE_NULL 537 | if has_tag or always_update 538 | set_tag(tag_name, total) 539 | end 540 | end 541 | end 542 | 543 | return total 544 | end 545 | 546 | def to_structure(include_titles = true) 547 | # Indented text output with items labelled by type, and project/task decoration stripped 548 | 549 | # Output own content, then children 550 | output = "" 551 | if @type != TYPE_NULL 552 | suffix = (include_titles) ? " #{title}" : "" 553 | output += "#{"\t" * (self.effective_level)}[#{type_name}]#{suffix}#{TaskPaperItem.linebreak}" 554 | end 555 | @children.each do |child| 556 | output += child.to_structure(include_titles) 557 | end 558 | return output 559 | end 560 | 561 | def to_tags(include_values = true) 562 | # Indented text output with just item types, tags, and values 563 | 564 | # Output own content, then children 565 | output = "" 566 | if @type != TYPE_NULL 567 | output += "#{"\t" * (self.effective_level)}[#{type_name}] " 568 | if @tags.length > 0 569 | @tags.each_with_index do |tag, index| 570 | output += "@#{tag[:name]}" 571 | if include_values and tag[:value].length > 0 572 | output += "(#{tag[:value]})" 573 | end 574 | if index < @tags.length - 1 575 | output += ", " 576 | end 577 | end 578 | else 579 | output += "(none)" 580 | end 581 | output += "#{TaskPaperItem.linebreak}" 582 | end 583 | @children.each do |child| 584 | output += child.to_tags(include_values) 585 | end 586 | return output 587 | end 588 | 589 | def all_tags(with_values = true, prefixed = false) 590 | # Text output with just item tags 591 | 592 | # Output own content, then children 593 | output = [] 594 | if @type != TYPE_NULL 595 | if @tags.length > 0 596 | prefix = (prefixed) ? "@" : "" 597 | @tags.each do |tag| 598 | tag_value = "" 599 | if (with_values) 600 | tag_val = tag_value(tag[:name]) 601 | end 602 | value_suffix = (with_values and tag_val != "") ? "(#{tag_val})" : "" 603 | output.push("#{prefix}#{tag[:name]}#{value_suffix}") 604 | end 605 | end 606 | end 607 | @children.each do |child| 608 | output += child.all_tags(with_values, prefixed) 609 | end 610 | return output 611 | end 612 | 613 | def to_links(add_missing_protocols = true) 614 | # Text output with just item links 615 | 616 | # Bare domains (domain.com) or email addresses (you@domain.com) are included; the add_missing_protocols parameter will prepend "http://" or "mailto:" as appropriate. 617 | 618 | # Output own content, then children 619 | output = "" 620 | if @type != TYPE_NULL 621 | if @links.length > 0 622 | key = (add_missing_protocols) ? :url : :text 623 | @links.each do |link| 624 | output += "#{link[key]}#{TaskPaperItem.linebreak}" 625 | end 626 | end 627 | end 628 | @children.each do |child| 629 | output += child.to_links(add_missing_protocols) 630 | end 631 | return output 632 | end 633 | 634 | def to_text 635 | # Indented text output of original content, with normalised (tab) indentation 636 | 637 | # Output own content, then children 638 | output = "" 639 | if @type != TYPE_NULL 640 | converted_content = TaskPaperExportPluginManager.process_text(self, @content, TaskPaperExportPlugin::OUTPUT_TYPE_TEXT) 641 | output += "#{"\t" * (self.effective_level)}#{converted_content}#{TaskPaperItem.linebreak}" 642 | end 643 | @children.each do |child| 644 | output += child.to_text 645 | end 646 | return output 647 | end 648 | 649 | def to_json 650 | # Hierarchical output of content as JSON: http://json.org 651 | 652 | output = "" 653 | if @type != TYPE_NULL 654 | converted_content = TaskPaperExportPluginManager.process_text(self, @content, TaskPaperExportPlugin::OUTPUT_TYPE_JSON) 655 | converted_content = json_escape(converted_content) 656 | output += "{" 657 | output += "\"content\": \"#{converted_content}\",#{TaskPaperItem.linebreak}" 658 | output += "\"title\": \"#{json_escape(title)}\",#{TaskPaperItem.linebreak}" 659 | output += "\"type\": \"#{@type}\",#{TaskPaperItem.linebreak}" 660 | output += "\"type_name\": \"#{type_name}\",#{TaskPaperItem.linebreak}" 661 | 662 | output += "\"id_attr\": \"#{json_escape(id_attr)}\",#{TaskPaperItem.linebreak}" 663 | output += "\"md5_hash\": \"#{json_escape(md5_hash)}\",#{TaskPaperItem.linebreak}" 664 | 665 | output += "\"level\": #{level},#{TaskPaperItem.linebreak}" 666 | output += "\"effective_level\": #{effective_level},#{TaskPaperItem.linebreak}" 667 | output += "\"extra_indent\": #{@extra_indent},#{TaskPaperItem.linebreak}" 668 | 669 | output += "\"done\": #{done?},#{TaskPaperItem.linebreak}" 670 | 671 | output += "\"tags\": [" 672 | @tags.each_with_index do |x, index| 673 | output += "{" 674 | output += "\"type\": \"#{x[:type]}\"," 675 | output += "\"name\": \"#{json_escape(x[:name])}\"," 676 | output += "\"value\": \"#{json_escape(x[:value])}\"," 677 | output += "\"begin\": #{x[:range].begin}," 678 | output += "\"end\": #{x[:range].end}" 679 | output += "}" 680 | if index < @tags.length - 1 681 | output += ", " 682 | end 683 | end 684 | output += "],#{TaskPaperItem.linebreak}" 685 | 686 | output += "\"links\": [" 687 | @links.each_with_index do |x, index| 688 | output += "{" 689 | output += "\"type\": \"#{x[:type]}\"," 690 | output += "\"text\": \"#{json_escape(x[:text])}\"," 691 | output += "\"url\": \"#{json_escape(x[:url])}\"," 692 | output += "\"begin\": #{x[:range].begin}," 693 | output += "\"end\": #{x[:range].end}" 694 | output += "}" 695 | if index < @links.length - 1 696 | output += ", " 697 | end 698 | end 699 | output += "],#{TaskPaperItem.linebreak}" 700 | 701 | output += "\"children\": " 702 | end 703 | output += "[" 704 | @children.each_with_index do |child, index| 705 | output += child.to_json 706 | if index < @children.length - 1 707 | output += ", " 708 | end 709 | end 710 | output += "]" 711 | if @type != TYPE_NULL 712 | output += "}" 713 | end 714 | return output 715 | end 716 | 717 | def json_escape(str) 718 | result = str.gsub(/\\/i, "\\\\\\").gsub(/(?#{text}" 18 | end 19 | end 20 | 21 | # Backtick code spans `...` 22 | backticks_regexp = /\`([^\`]+)\`/i 23 | matches = run_text.to_enum(:scan, backticks_regexp).map { Regexp.last_match } 24 | matches.reverse.each do |match| 25 | text = match[1] 26 | # Encode some entities 27 | text = text.gsub('&', '&').gsub('<', '<').gsub('>', '>') 28 | range = Range.new(match.begin(0), match.end(0), true) 29 | tag = "code" 30 | run_text[range] = "<#{tag}>#{text}" 31 | end 32 | end 33 | 34 | return run_text 35 | end 36 | 37 | end 38 | -------------------------------------------------------------------------------- /src/taskpapertagiconsexportplugin.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | 3 | class TaskPaperTagIconsExportPlugin < TaskPaperExportPlugin 4 | 5 | @tags = { 6 | "flag" => {"replacement" => '⚑'}, 7 | "priority" => {"values" => {"high" => "‼️", "1" => "‼️"}}, 8 | "done" => {"replacement" => '✔'}, 9 | "search" => {"replacement" => '🔍'} 10 | } 11 | 12 | class << self 13 | attr_accessor :tags 14 | end 15 | 16 | def process_tag_name(item, run_text, output_type, before_conversion = true, options = {}) 17 | if output_type == OUTPUT_TYPE_HTML 18 | return tag_icon(run_text) 19 | end 20 | 21 | return run_text 22 | end 23 | 24 | def process_tag_value(item, run_text, output_type, before_conversion = true, options = {}) 25 | if output_type == OUTPUT_TYPE_HTML 26 | return tag_icon(options["tagname"], run_text) 27 | end 28 | 29 | return run_text 30 | end 31 | 32 | def tag_icon(tagname, tagval = nil) 33 | wants_value = (tagval != nil) 34 | result = (wants_value) ? tagval : tagname 35 | if TaskPaperTagIconsExportPlugin.tags.include?(tagname) 36 | tag_info = TaskPaperTagIconsExportPlugin.tags[tagname] 37 | if wants_value 38 | # Tag value 39 | if tag_info.include?("values") 40 | vals = tag_info["values"] 41 | if vals.include?(tagval) 42 | return "#{vals[tagval]}" 43 | end 44 | end 45 | else 46 | # Tag name 47 | if tag_info.include?("replacement") 48 | return "#{tag_info["replacement"]}" 49 | end 50 | end 51 | end 52 | 53 | return result 54 | end 55 | 56 | end 57 | -------------------------------------------------------------------------------- /src/taskpaperthemeconverter.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | 3 | class TaskPaperThemeConverter 4 | 5 | =begin 6 | Requires Less CSS: http://lesscss.org 7 | If you're on a Mac, the easiest way to get Less is: 8 | 9 | 1. Install Homebrew: (instructions at http://brew.sh ). 10 | 2. Use Homebrew to install npm: "brew install npm" in Terminal. 11 | 3. Use npm to install Less: "npm install -g less" in Terminal. 12 | 13 | Now you can use Less via "lessc" in Terminal. 14 | =end 15 | 16 | def initialize(output_file_path) 17 | @base_theme_path = "/Applications/TaskPaper.app/Contents/Resources/base.less" 18 | @user_theme_path = "~/Library/Application Support/TaskPaper/theme.less" 19 | #@base_theme_path = "/Applications/TaskPaper Preview.app/Contents/Resources/base.less" 20 | #@user_theme_path = "/Applications/TaskPaper Preview.app/Contents/Resources/template.user.less" 21 | @css_tweaks_path = File.dirname(__FILE__)+"/css/tweaks.css" 22 | @css_fallback_path = File.dirname(__FILE__)+"/css/fallback.css" 23 | @output_file_path = output_file_path 24 | 25 | convert_theme 26 | end 27 | 28 | def file_contents(path, expand=true) 29 | contents = "" 30 | if expand 31 | path = File.expand_path(path) 32 | end 33 | if File.exists?(path) 34 | File.open(path, 'r') do |the_file| 35 | while line = the_file.gets 36 | contents += line; 37 | end 38 | end 39 | else 40 | puts "Couldn't find file: #{path}" 41 | end 42 | return contents 43 | end 44 | 45 | def write_file(contents, path) 46 | File.open(File.expand_path(path), 'w') do |outfile| 47 | outfile.puts contents 48 | end 49 | end 50 | 51 | def less_installed? 52 | result = `which lessc` 53 | return (result != "") 54 | end 55 | 56 | def less_convert(content) 57 | result = "" 58 | tmp_file = "/tmp/taskpaperthemeconvertertemp.txt" 59 | write_file(content, tmp_file) 60 | `lessc #{tmp_file} #{tmp_file}` 61 | result = file_contents(tmp_file, false) 62 | File.delete(tmp_file) 63 | 64 | return result 65 | end 66 | 67 | def convert_theme 68 | # Load TaskPaper theme files and CSS tweaks 69 | if less_installed? 70 | raw_content = "" 71 | raw_content += file_contents(@base_theme_path) + "\n" 72 | user_theme_offset = raw_content.length 73 | raw_content += file_contents(@user_theme_path) + "\n" 74 | tweaks_offset = raw_content.length 75 | raw_content += file_contents(@css_tweaks_path, false) 76 | 77 | # Make some modifications 78 | #css_selector_regexp = /([a-zA-Z\-]+)\s*:\s*([^;]+)\s*;/i 79 | 80 | # Handle colour-overriding due to (correct) LI>UL HTML nesting, 81 | # and reflect default link styling in TaskPaper 82 | task_color_override = < 0 164 | link_color = link_color_matches[-1][1].strip 165 | end 166 | raw_content.gsub!("$LINK_COLOR", link_color) # in tweaks CSS file 167 | 168 | # Selection background colour 169 | sel_bg_regexp = /(? 0 172 | sel_bg_color = sel_bg_matches[-1][1].strip 173 | raw_content += "\n::selection { background: #{sel_bg_color}; }\n" 174 | end 175 | 176 | # Handle-colour (for future versions of browsers) 177 | handle_color_regexp = /(? 0 181 | handle_color = handle_color_matches[-1][1].strip 182 | end 183 | raw_content.gsub!("$HANDLE_COLOR", handle_color) # in tweaks CSS file 184 | 185 | # Item indent 186 | item_indent_regexp = /(? 0 190 | item_indent = item_indent_matches[-1][1].strip 191 | end 192 | raw_content.gsub!("$ITEM_INDENT", item_indent) # in tweaks CSS file 193 | 194 | # Line height 195 | raw_content.gsub!("line-height-multiple", "line-height") 196 | 197 | # Strip inapplicable/incompatible selectors 198 | strip_selectors = [ 199 | "search-item-prefix", 200 | "caret-width", 201 | "caret-color", 202 | "invisibles-color", 203 | "drop-indicator-color", 204 | "guide-line-color", 205 | "guide-line-width", 206 | "message-color", 207 | "item-indent", # handled above 208 | "folded-items-label", 209 | "filtered-items-label", 210 | "item-handle-size", 211 | "handle-color", # handled above 212 | "selection-background-color", # handled above 213 | "text-underline-color", 214 | "text-strikethrough-color", 215 | "text-expansion", 216 | "text-baseline-offset", 217 | ] 218 | strip_selectors.each do |sel| 219 | raw_content.gsub!(/((? 2 38 | html_template_file_path = ARGV[2] 39 | end 40 | 41 | input_file_path = File.expand_path(ARGV[0]) 42 | html_output_file_path = File.expand_path(ARGV[1]) 43 | html_template_file_path = File.expand_path(html_template_file_path) 44 | css_output_file_path = File.join(File.dirname(html_output_file_path), css_output_filename) 45 | 46 | # Ensure we have an input file to work with 47 | if !File.exist?(input_file_path) 48 | puts "Couldn't find input file \"#{input_file_path}\". ¯\\_(ツ)_/¯" 49 | exit 50 | end 51 | 52 | # Ensure we have a template file to work with 53 | if !File.exist?(html_template_file_path) 54 | puts "Couldn't find input file \"#{html_template_file_path}\". ¯\\_(ツ)_/¯" 55 | exit 56 | end 57 | 58 | # Load TaskPaper file 59 | document = TaskPaperDocument.new(input_file_path) 60 | 61 | # Enable some export plugins 62 | TaskPaperExportPluginManager.add_plugin(TaskPaperEntityEncodingExportPlugin.new) 63 | TaskPaperExportPluginManager.add_plugin(TaskPaperMarkdownExportPlugin.new) 64 | TaskPaperExportPluginManager.add_plugin(TaskPaperEmoticonsExportPlugin.new) 65 | TaskPaperExportPluginManager.add_plugin(TaskPaperTagIconsExportPlugin.new) 66 | 67 | # Produce HTML output from document 68 | document_html = document.to_html 69 | sidebar_html = document.to_sidebar 70 | 71 | # Load HTML template 72 | template_contents = "" 73 | File.open(html_template_file_path, 'r') do |the_file| 74 | while line = the_file.gets 75 | template_contents += line; 76 | end 77 | end 78 | 79 | # Insert data into template 80 | template_vars = { 81 | # All vars are prefixed with "tp-" in the template file. 82 | "sidebar-html" => sidebar_html, 83 | "document-html" => document_html, 84 | "document-title" => File.basename(input_file_path, ".*"), 85 | "document-filename" => File.basename(input_file_path), 86 | "css-output-filename" => css_output_filename, 87 | } 88 | 89 | template_vars.each { |key, value| 90 | template_contents.gsub!(/\{\{\s*tp-#{key}\s*\}\}/i, value) 91 | } 92 | 93 | # Write HTML file 94 | File.open(html_output_file_path, 'w') do |outfile| 95 | outfile.puts template_contents 96 | end 97 | 98 | # Write CSS file 99 | TaskPaperThemeConverter.new(css_output_file_path) 100 | -------------------------------------------------------------------------------- /template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ tp-document-title }} 5 | 6 | 7 | 8 | 9 |
    {{ tp-sidebar-html }}
    10 |
    {{ tp-document-html }}
    11 | 12 | 13 | --------------------------------------------------------------------------------