├── .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 | 
36 |
37 | And here's the resulting HTML file:
38 |
39 | 
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}#{tag}>"
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}#{tag}>"
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 |
10 | {{ tp-document-html }}
11 |
12 |
13 |
--------------------------------------------------------------------------------