aboutsummaryrefslogtreecommitdiffstats
path: root/plugins/utilities
diff options
context:
space:
mode:
authorshu <nvcsadjkojf@outlook.com>2026-08-19 15:56:06 +0800
committershu <nvcsadjkojf@outlook.com>2026-08-19 15:56:06 +0800
commitca1a4a31706d0c03673fe4c414c645ed826b21c6 (patch)
treec412265a19dd4694c39aac65b6e1a8903a513978 /plugins/utilities
parent490d41f154615a52734f117d7d7bda5a9331e303 (diff)
精简 Wiki 构建与运行方式
将浏览器端工具整理为 utilities 插件,移除 Docker、Org GTD 和 files API,并补充单文件构建及 Node.js 运行入口。
Diffstat (limited to 'plugins/utilities')
-rw-r--r--plugins/utilities/copy-codeblock-widget.tid72
-rw-r--r--plugins/utilities/current-tiddler-toc-sidebar.tid69
-rw-r--r--plugins/utilities/current-tiddler-toc-widget.tid284
-rw-r--r--plugins/utilities/highlight-caddyfile.tid65
-rw-r--r--plugins/utilities/highlight-json5.tid59
-rw-r--r--plugins/utilities/highlight-tiddlywiki.tid191
-rw-r--r--plugins/utilities/journal-calendar-widget.tid415
-rw-r--r--plugins/utilities/plugin.info11
-rw-r--r--plugins/utilities/readme.tid8
9 files changed, 1174 insertions, 0 deletions
diff --git a/plugins/utilities/copy-codeblock-widget.tid b/plugins/utilities/copy-codeblock-widget.tid
new file mode 100644
index 0000000..2251c62
--- /dev/null
+++ b/plugins/utilities/copy-codeblock-widget.tid
@@ -0,0 +1,72 @@
+title: $:/plugins/local/copy-codeblock/copy-codeblock.js
+type: application/javascript
+module-type: widget
+
+/*\
+title: $:/plugins/local/copy-codeblock/copy-codeblock.js
+type: application/javascript
+module-type: widget
+
+Adds a copy button to code blocks.
+\*/
+(function() {
+"use strict";
+
+var CodeBlockWidget = require("$:/core/modules/widgets/codeblock.js").codeblock;
+
+var CopyCodeBlockWidget = function(parseTreeNode,options) {
+ this.initialise(parseTreeNode,options);
+};
+
+CopyCodeBlockWidget.prototype = new CodeBlockWidget();
+CopyCodeBlockWidget.prototype.constructor = CopyCodeBlockWidget;
+
+CopyCodeBlockWidget.prototype.postRender = function() {
+ var self = this,
+ preNode = this.domNodes[0],
+ previousNode,
+ button;
+
+ // Preserve syntax highlighting supplied by the highlight plugin.
+ if(CodeBlockWidget.prototype.postRender) {
+ CodeBlockWidget.prototype.postRender.call(this);
+ }
+
+ if(!$tw.browser || !preNode || preNode.isTiddlyWikiFakeDom) {
+ return;
+ }
+
+ // The system code view already renders its own copy button.
+ previousNode = preNode.previousElementSibling;
+ if(previousNode && previousNode.querySelector && previousNode.querySelector(".tc-image-copy-clipboard")) {
+ return;
+ }
+
+ preNode.style.position = "relative";
+ preNode.style.paddingRight = "5em";
+
+ button = this.document.createElement("button");
+ button.setAttribute("type","button");
+ button.setAttribute("aria-label","Copy code");
+ button.setAttribute("title","Copy code");
+ button.className = "tc-btn-invisible tc-copy-codeblock-button";
+ button.textContent = "Copy";
+ button.style.position = "absolute";
+ button.style.top = "0.5em";
+ button.style.right = "0.5em";
+ button.style.zIndex = "1";
+ button.style.padding = "0.2em 0.45em";
+ button.style.cursor = "pointer";
+
+ button.addEventListener("click",function(event) {
+ event.preventDefault();
+ event.stopPropagation();
+ $tw.utils.copyToClipboard(self.getAttribute("code",""));
+ });
+
+ preNode.appendChild(button);
+};
+
+exports.codeblock = CopyCodeBlockWidget;
+
+})();
diff --git a/plugins/utilities/current-tiddler-toc-sidebar.tid b/plugins/utilities/current-tiddler-toc-sidebar.tid
new file mode 100644
index 0000000..9d73d6d
--- /dev/null
+++ b/plugins/utilities/current-tiddler-toc-sidebar.tid
@@ -0,0 +1,69 @@
+title: $:/plugins/local/current-tiddler-toc/sidebar
+type: text/vnd.tiddlywiki
+tags: $:/tags/SideBar
+caption: 目录
+list-after: $:/core/ui/SideBar/More
+
+<style>
+.tc-current-tiddler-toc {
+ font-size: 0.95em;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ scrollbar-gutter: stable;
+ padding-right: 0.25em;
+}
+
+.tc-current-tiddler-toc-group {
+ margin: 0 0 1em;
+}
+
+.tc-current-tiddler-toc-title {
+ display: block;
+ width: 100%;
+ font-weight: 600;
+ margin: 0 0 0.35em;
+ padding: 0.2em 0;
+ text-align: left;
+ overflow-wrap: anywhere;
+}
+
+.tc-current-tiddler-toc-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.tc-current-tiddler-toc-item {
+ margin: 0;
+ padding-top: 0.2em;
+ padding-bottom: 0.2em;
+}
+
+.tc-current-tiddler-toc-link {
+ display: block;
+ width: 100%;
+ padding: 0.2em 0;
+ text-align: left;
+ line-height: 1.35;
+ overflow-wrap: anywhere;
+}
+
+.tc-current-tiddler-toc-link:hover,
+.tc-current-tiddler-toc-link:focus {
+ text-decoration: underline;
+}
+
+.tc-current-tiddler-toc-empty {
+ color: <<colour muted-foreground>>;
+ font-style: italic;
+}
+
+.tc-current-tiddler-toc-group-empty {
+ padding-left: 1.1em;
+ color: <<colour muted-foreground>>;
+ font-size: 0.9em;
+ font-style: italic;
+}
+</style>
+
+<$current-tiddler-toc/>
diff --git a/plugins/utilities/current-tiddler-toc-widget.tid b/plugins/utilities/current-tiddler-toc-widget.tid
new file mode 100644
index 0000000..a68e449
--- /dev/null
+++ b/plugins/utilities/current-tiddler-toc-widget.tid
@@ -0,0 +1,284 @@
+title: $:/plugins/local/current-tiddler-toc/widget.js
+type: application/javascript
+module-type: widget
+
+/*\
+title: $:/plugins/local/current-tiddler-toc/widget.js
+type: application/javascript
+module-type: widget
+
+Displays a table of contents for the current story tiddler.
+\*/
+(function() {
+"use strict";
+
+var Widget = require("$:/core/modules/widgets/widget.js").widget;
+
+var CurrentTiddlerTocWidget = function(parseTreeNode,options) {
+ this.initialise(parseTreeNode,options);
+};
+
+CurrentTiddlerTocWidget.prototype = new Widget();
+CurrentTiddlerTocWidget.prototype.constructor = CurrentTiddlerTocWidget;
+
+CurrentTiddlerTocWidget.prototype.render = function(parent,nextSibling) {
+ this.parentDomNode = parent;
+ this.computeAttributes();
+ this.execute();
+
+ var container = this.document.createElement("div");
+ container.className = "tc-current-tiddler-toc";
+
+ parent.insertBefore(container,nextSibling);
+ this.domNodes.push(container);
+ this.containerNode = container;
+
+ if($tw.browser) {
+ var self = this,
+ windowObject = this.document.defaultView || window;
+
+ this.windowObject = windowObject;
+ this.resizeHandler = function() {
+ self.updateMaxHeight();
+ };
+ windowObject.addEventListener("resize",this.resizeHandler);
+ if(windowObject.visualViewport) {
+ windowObject.visualViewport.addEventListener("resize",this.resizeHandler);
+ }
+ }
+
+ this.scheduleBuild();
+};
+
+CurrentTiddlerTocWidget.prototype.execute = function() {
+};
+
+CurrentTiddlerTocWidget.prototype.getStoryTitles = function() {
+ return this.wiki.getTiddlerList("$:/StoryList") || [];
+};
+
+CurrentTiddlerTocWidget.prototype.findTiddlerFrame = function(title) {
+ var frames = this.document.querySelectorAll(".tc-tiddler-frame[data-tiddler-title]"),
+ index;
+
+ for(index = 0; index < frames.length; index++) {
+ var frame = frames[index],
+ frameTitle = frame.getAttribute("data-tiddler-title"),
+ frameTiddler;
+
+ if(frameTitle === title) {
+ return frame;
+ }
+
+ frameTiddler = this.wiki.getTiddler(frameTitle);
+
+ if(frameTiddler && (frameTiddler.fields["draft.of"] === title || frameTiddler.fields["draft.title"] === title)) {
+ return frame;
+ }
+ }
+
+ return null;
+};
+
+CurrentTiddlerTocWidget.prototype.clearContainer = function() {
+ while(this.containerNode && this.containerNode.firstChild) {
+ this.containerNode.removeChild(this.containerNode.firstChild);
+ }
+};
+
+CurrentTiddlerTocWidget.prototype.showMessage = function(message) {
+ var messageNode = this.document.createElement("div");
+
+ messageNode.className = "tc-current-tiddler-toc-empty";
+ messageNode.textContent = message;
+ this.containerNode.appendChild(messageNode);
+};
+
+CurrentTiddlerTocWidget.prototype.updateMaxHeight = function() {
+ if(!this.containerNode || !this.containerNode.parentNode || !this.windowObject) {
+ return;
+ }
+
+ var viewport = this.windowObject.visualViewport,
+ viewportHeight = viewport ? viewport.height : this.windowObject.innerHeight,
+ top = this.containerNode.getBoundingClientRect().top,
+ availableHeight = Math.max(160,Math.floor(viewportHeight - top - 16));
+
+ this.containerNode.style.maxHeight = availableHeight + "px";
+};
+
+CurrentTiddlerTocWidget.prototype.getHeadings = function(frame) {
+ var body = frame.querySelector(".tc-tiddler-body");
+
+ if(!body) {
+ body = frame.querySelector(".tc-tiddler-preview-preview");
+ }
+
+ if(!body) {
+ return [];
+ }
+
+ var headingNodes = body.querySelectorAll("h1, h2, h3, h4, h5, h6"),
+ headings = [],
+ index;
+
+ for(index = 0; index < headingNodes.length; index++) {
+ var heading = headingNodes[index];
+
+ // Ignore headings that belong to nested, independent tiddler frames.
+ if(heading.closest && heading.closest(".tc-tiddler-frame") !== frame) {
+ continue;
+ }
+
+ headings.push({
+ node: heading,
+ level: parseInt(heading.tagName.substring(1),10),
+ text: heading.textContent.trim() || "未命名标题"
+ });
+ }
+
+ return headings;
+};
+
+CurrentTiddlerTocWidget.prototype.addTiddlerGroup = function(title,frame) {
+ var self = this,
+ groupNode = this.document.createElement("section"),
+ titleButton = this.document.createElement("button");
+
+ groupNode.className = "tc-current-tiddler-toc-group";
+ titleButton.setAttribute("type","button");
+ titleButton.className = "tc-btn-invisible tc-current-tiddler-toc-title";
+ titleButton.textContent = title;
+ titleButton.title = "滚动到条目:" + title;
+ titleButton.addEventListener("click",function(event) {
+ event.preventDefault();
+ frame.scrollIntoView({
+ behavior: "smooth",
+ block: "start"
+ });
+ });
+ groupNode.appendChild(titleButton);
+
+ var headings = this.getHeadings(frame);
+
+ if(!headings.length) {
+ var emptyNode = this.document.createElement("div");
+ emptyNode.className = "tc-current-tiddler-toc-group-empty";
+ emptyNode.textContent = "没有标题";
+ groupNode.appendChild(emptyNode);
+ this.containerNode.appendChild(groupNode);
+ return;
+ }
+
+ var minimumLevel = headings.reduce(function(result,heading) {
+ return Math.min(result,heading.level);
+ },6),
+ listNode = this.document.createElement("ol");
+
+ listNode.className = "tc-current-tiddler-toc-list";
+ groupNode.appendChild(listNode);
+
+ headings.forEach(function(heading) {
+ var itemNode = self.document.createElement("li"),
+ buttonNode = self.document.createElement("button");
+
+ itemNode.className = "tc-current-tiddler-toc-item";
+ itemNode.style.paddingLeft = ((heading.level - minimumLevel) * 1.1) + "em";
+
+ buttonNode.setAttribute("type","button");
+ buttonNode.className = "tc-btn-invisible tc-current-tiddler-toc-link";
+ buttonNode.textContent = heading.text;
+ buttonNode.title = heading.text;
+ buttonNode.addEventListener("click",function(event) {
+ event.preventDefault();
+ heading.node.scrollIntoView({
+ behavior: "smooth",
+ block: "start"
+ });
+ });
+
+ itemNode.appendChild(buttonNode);
+ listNode.appendChild(itemNode);
+ });
+
+ this.containerNode.appendChild(groupNode);
+};
+
+CurrentTiddlerTocWidget.prototype.build = function() {
+ if(!this.containerNode || !this.containerNode.parentNode) {
+ return;
+ }
+
+ this.clearContainer();
+
+ var titles = this.getStoryTitles(),
+ index,
+ visibleCount = 0;
+
+ if(!titles.length) {
+ this.showMessage("当前没有打开的条目");
+ this.updateMaxHeight();
+ return;
+ }
+
+ for(index = 0; index < titles.length; index++) {
+ var title = titles[index],
+ frame = this.findTiddlerFrame(title);
+
+ if(frame) {
+ this.addTiddlerGroup(title,frame);
+ visibleCount += 1;
+ }
+ }
+
+ if(!visibleCount) {
+ this.showMessage("打开的条目尚未显示");
+ }
+
+ this.updateMaxHeight();
+};
+
+CurrentTiddlerTocWidget.prototype.scheduleBuild = function() {
+ var self = this;
+
+ if(!$tw.browser) {
+ return;
+ }
+
+ if(this.buildTimer) {
+ clearTimeout(this.buildTimer);
+ }
+
+ // Wait until the story river has completed the current DOM update.
+ this.buildTimer = setTimeout(function() {
+ self.buildTimer = null;
+ self.build();
+ },0);
+};
+
+CurrentTiddlerTocWidget.prototype.refresh = function() {
+ this.computeAttributes();
+ this.scheduleBuild();
+ return false;
+};
+
+CurrentTiddlerTocWidget.prototype.removeChildDomNodes = function() {
+ if(this.buildTimer) {
+ clearTimeout(this.buildTimer);
+ this.buildTimer = null;
+ }
+ if(this.windowObject && this.resizeHandler) {
+ this.windowObject.removeEventListener("resize",this.resizeHandler);
+ if(this.windowObject.visualViewport) {
+ this.windowObject.visualViewport.removeEventListener("resize",this.resizeHandler);
+ }
+ this.resizeHandler = null;
+ this.windowObject = null;
+ }
+
+ Widget.prototype.removeChildDomNodes.call(this);
+};
+
+exports["current-tiddler-toc"] = CurrentTiddlerTocWidget;
+
+})();
diff --git a/plugins/utilities/highlight-caddyfile.tid b/plugins/utilities/highlight-caddyfile.tid
new file mode 100644
index 0000000..8427c3f
--- /dev/null
+++ b/plugins/utilities/highlight-caddyfile.tid
@@ -0,0 +1,65 @@
+tiddlers/highlight-caddyfile.tid
+title: $:/plugins/local/highlight-languages/caddyfile.js
+type: application/javascript
+module-type: highlight
+
+/*\
+title: $:/plugins/local/highlight-languages/caddyfile.js
+type: application/javascript
+module-type: highlight
+
+Caddyfile language definition for highlight.js.
+\*/
+"use strict";
+
+hljs.registerLanguage("caddyfile",function(hljs) {
+ return {
+ name: "Caddyfile",
+ aliases: ["caddy"],
+ case_insensitive: true,
+ contains: [
+ {
+ className: "comment",
+ begin: /#/,
+ end: /$/
+ },
+ {
+ className: "section",
+ begin: /^\s*(?=[^#\n{]*(?:[.:,]|\blocalhost\b))(?:https?:\/\/)?[^\s#{}]+(?:\s*,\s*[^\s#{}]+)*(?=\s*\{?\s*$)/,
+ relevance: 10
+ },
+ {
+ className: "keyword",
+ begin: /^\s*[A-Za-z_][A-Za-z0-9_-]*(?=\s|$)/,
+ relevance: 1
+ },
+ {
+ className: "meta",
+ match: /@[A-Za-z0-9_-]+/
+ },
+ {
+ className: "variable",
+ match: /\{\$?[A-Za-z_][A-Za-z0-9_.\-[\]*]*\}/
+ },
+ {
+ className: "title",
+ match: /\([A-Za-z_][A-Za-z0-9_-]*\)/
+ },
+ hljs.QUOTE_STRING_MODE,
+ hljs.APOS_STRING_MODE,
+ {
+ className: "literal",
+ match: /\b(?:true|false|on|off)\b/
+ },
+ {
+ className: "number",
+ match: /\b\d+(?:\.\d+)?(?:ns|us|ms|s|m|h|d|kb|mb|gb|tb)?\b/i,
+ relevance: 0
+ },
+ {
+ className: "punctuation",
+ match: /[{}]/
+ }
+ ]
+ };
+});
diff --git a/plugins/utilities/highlight-json5.tid b/plugins/utilities/highlight-json5.tid
new file mode 100644
index 0000000..fae9bd4
--- /dev/null
+++ b/plugins/utilities/highlight-json5.tid
@@ -0,0 +1,59 @@
+title: $:/plugins/local/highlight-languages/json5.js
+type: application/javascript
+module-type: highlight
+
+/*\
+title: $:/plugins/local/highlight-languages/json5.js
+type: application/javascript
+module-type: highlight
+
+JSON5 language definition for highlight.js.
+\*/
+"use strict";
+
+hljs.registerLanguage("json5",function(hljs) {
+ return {
+ name: "JSON5",
+ aliases: ["json5"],
+ keywords: {
+ literal: "true false null"
+ },
+ contains: [
+ hljs.C_LINE_COMMENT_MODE,
+ hljs.C_BLOCK_COMMENT_MODE,
+ {
+ className: "attr",
+ match: /[A-Za-z_$][0-9A-Za-z_$]*(?=\s*:)/
+ },
+ {
+ className: "attr",
+ match: /"(?:\\.|[^"\\\r\n])*"(?=\s*:)/
+ },
+ {
+ className: "attr",
+ match: /'(?:\\.|[^'\\\r\n])*'(?=\s*:)/
+ },
+ hljs.QUOTE_STRING_MODE,
+ hljs.APOS_STRING_MODE,
+ {
+ className: "number",
+ variants: [
+ {
+ begin: /[+-]?(?:Infinity|NaN)\b/
+ },
+ {
+ begin: /[+-]?0[xX][0-9a-fA-F]+\b/
+ },
+ {
+ begin: /[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/
+ }
+ ],
+ relevance: 0
+ },
+ {
+ className: "punctuation",
+ match: /[{}\[\],:]/
+ }
+ ]
+ };
+});
diff --git a/plugins/utilities/highlight-tiddlywiki.tid b/plugins/utilities/highlight-tiddlywiki.tid
new file mode 100644
index 0000000..dd1a891
--- /dev/null
+++ b/plugins/utilities/highlight-tiddlywiki.tid
@@ -0,0 +1,191 @@
+title: $:/plugins/local/highlight-languages/tiddlywiki.js
+type: application/javascript
+module-type: highlight
+
+/*\
+title: $:/plugins/local/highlight-languages/tiddlywiki.js
+type: application/javascript
+module-type: highlight
+
+TiddlyWiki TID and WikiText language definition for highlight.js.
+\*/
+"use strict";
+
+hljs.registerLanguage("tiddlywiki",function(hljs) {
+ var systemTiddler = {
+ className: "symbol",
+ match: /\$:\/[^\s\][\]<>{}"']+/
+ },
+ variable = {
+ className: "variable",
+ variants: [
+ {
+ begin: /<</,
+ end: />>/
+ },
+ {
+ match: /<[^<>\s]+>/
+ }
+ ]
+ },
+ transclusion = {
+ className: "template-variable",
+ variants: [
+ {
+ begin: /\{\{\{/,
+ end: /\}\}\}/
+ },
+ {
+ begin: /\{\{/,
+ end: /\}\}/
+ }
+ ],
+ contains: [
+ systemTiddler,
+ variable
+ ]
+ },
+ link = {
+ className: "link",
+ variants: [
+ {
+ begin: /\[\[/,
+ end: /\]\]/
+ },
+ {
+ begin: /\[(?:img|ext)\[/,
+ end: /\]\]/
+ }
+ ],
+ contains: [
+ systemTiddler
+ ]
+ },
+ filter = {
+ className: "selector",
+ begin: /\[(?=[^\]\n]*[A-Za-z][A-Za-z0-9_-]*\[)/,
+ end: /\]/,
+ contains: [
+ {
+ className: "keyword",
+ match: /[A-Za-z][A-Za-z0-9_.-]*(?=\[)/
+ },
+ {
+ className: "string",
+ begin: /\[/,
+ end: /\]/,
+ contains: [
+ systemTiddler,
+ variable
+ ]
+ },
+ variable,
+ systemTiddler
+ ]
+ };
+
+ return {
+ name: "TiddlyWiki",
+ aliases: [
+ "tid",
+ "wikitext"
+ ],
+ contains: [
+ {
+ className: "comment",
+ begin: /<!--/,
+ end: /-->/
+ },
+ {
+ className: "comment",
+ begin: /\/%/,
+ end: /%\//
+ },
+ {
+ className: "meta",
+ begin: /^\\(?:define|end|function|import|parameters|procedure|rules|whitespace|widget)\b/,
+ end: /$/,
+ contains: [
+ variable,
+ systemTiddler,
+ hljs.QUOTE_STRING_MODE,
+ hljs.APOS_STRING_MODE
+ ]
+ },
+ {
+ className: "attr",
+ match: /^[A-Za-z0-9_.-]+(?=\s*:)/
+ },
+ {
+ className: "section",
+ begin: /^!{1,6}\s+/,
+ end: /$/
+ },
+ {
+ className: "bullet",
+ match: /^[*#;:]+\s+/
+ },
+ {
+ className: "quote",
+ begin: /^>{1,3}\s+/,
+ end: /$/
+ },
+ {
+ className: "tag",
+ begin: /<\/?\$?[A-Za-z][A-Za-z0-9_.:-]*/,
+ end: /\/?>/,
+ contains: [
+ {
+ className: "attr",
+ match: /[A-Za-z_$][A-Za-z0-9_.:-]*(?=\s*=)/
+ },
+ hljs.QUOTE_STRING_MODE,
+ hljs.APOS_STRING_MODE,
+ transclusion,
+ variable,
+ filter,
+ systemTiddler
+ ]
+ },
+ {
+ className: "template-tag",
+ begin: /<</,
+ end: />>/,
+ contains: [
+ hljs.QUOTE_STRING_MODE,
+ hljs.APOS_STRING_MODE,
+ systemTiddler
+ ]
+ },
+ transclusion,
+ link,
+ filter,
+ systemTiddler,
+ {
+ className: "code",
+ begin: /`+/,
+ end: /`+/
+ },
+ {
+ className: "strong",
+ begin: /''/,
+ end: /''/
+ },
+ {
+ className: "emphasis",
+ begin: /\/\//,
+ end: /\/\//
+ },
+ {
+ className: "emphasis",
+ begin: /__/,
+ end: /__/
+ },
+ {
+ className: "deletion",
+ begin: /~~/,
+ end: /~~/
+ }
+ ]
+ };
+});
diff --git a/plugins/utilities/journal-calendar-widget.tid b/plugins/utilities/journal-calendar-widget.tid
new file mode 100644
index 0000000..c16276d
--- /dev/null
+++ b/plugins/utilities/journal-calendar-widget.tid
@@ -0,0 +1,415 @@
+title: $:/plugins/local/journal-calendar/widget.js
+type: application/javascript
+module-type: widget
+
+/*\
+title: $:/plugins/local/journal-calendar/widget.js
+type: application/javascript
+module-type: widget
+
+Renders journal tiddlers as monthly calendars.
+\*/
+(function() {
+"use strict";
+
+var Widget = require("$:/core/modules/widgets/widget.js").widget,
+ VIEW_STATE_TITLE = "$:/state/journal-calendar/view",
+ MONTH_STATE_TITLE = "$:/state/journal-calendar/month";
+
+var JournalCalendarWidget = function(parseTreeNode,options) {
+ this.initialise(parseTreeNode,options);
+};
+
+JournalCalendarWidget.prototype = new Widget();
+JournalCalendarWidget.prototype.constructor = JournalCalendarWidget;
+
+function pad(value) {
+ return value < 10 ? "0" + value : "" + value;
+}
+
+function dateKey(year,month,day) {
+ return year + "-" + pad(month) + "-" + pad(day);
+}
+
+function monthKey(year,month) {
+ return year + "-" + pad(month);
+}
+
+function isValidMonth(value) {
+ var match = /^(\d{4})-(\d{2})$/.exec(value || "");
+ return !!match && Number(match[2]) >= 1 && Number(match[2]) <= 12;
+}
+
+function parseMonth(value) {
+ var parts = value.split("-");
+ return {
+ year: Number(parts[0]),
+ month: Number(parts[1])
+ };
+}
+
+function formatMonth(value) {
+ var month = parseMonth(value);
+ return month.year + "年" + month.month + "月";
+}
+
+JournalCalendarWidget.prototype.render = function(parent,nextSibling) {
+ this.parentDomNode = parent;
+ this.computeAttributes();
+ this.execute();
+
+ var container = this.document.createElement("div");
+ container.className = "tc-journal-calendar";
+ parent.insertBefore(container,nextSibling);
+ this.domNodes.push(container);
+ this.containerNode = container;
+
+ this.renderCalendar();
+};
+
+JournalCalendarWidget.prototype.execute = function() {
+ this.tag = this.getAttribute("tag","日志");
+ this.view = this.getAttribute("view","switchable");
+ this.month = this.getAttribute("month","");
+};
+
+JournalCalendarWidget.prototype.getJournalDate = function(tiddler) {
+ var titleMatch = /^(\d{4})年(\d{2})月(\d{2})日$/.exec(tiddler.fields.title),
+ date;
+
+ if(titleMatch) {
+ date = new Date(Number(titleMatch[1]),Number(titleMatch[2]) - 1,Number(titleMatch[3]));
+ if(date.getFullYear() === Number(titleMatch[1]) && date.getMonth() === Number(titleMatch[2]) - 1 && date.getDate() === Number(titleMatch[3])) {
+ return date;
+ }
+ }
+ return null;
+};
+
+JournalCalendarWidget.prototype.getJournalData = function() {
+ var titles = this.wiki.getTiddlersWithTag(this.tag),
+ byDate = Object.create(null),
+ months = Object.create(null),
+ self = this;
+
+ titles.forEach(function(title) {
+ var tiddler = self.wiki.getTiddler(title);
+ if(!tiddler || tiddler.fields["draft.of"]) {
+ return;
+ }
+
+ var date = self.getJournalDate(tiddler);
+ if(!date) {
+ return;
+ }
+
+ var day = dateKey(date.getFullYear(),date.getMonth() + 1,date.getDate()),
+ month = day.substring(0,7);
+ byDate[day] = {
+ title: title,
+ tags: (tiddler.fields.tags || []).filter(function(tag) {
+ return tag !== self.tag;
+ })
+ };
+ months[month] = true;
+ });
+
+ return {
+ byDate: byDate,
+ months: Object.keys(months).sort().reverse()
+ };
+};
+
+JournalCalendarWidget.prototype.getCurrentMonth = function() {
+ if(isValidMonth(this.month)) {
+ return this.month;
+ }
+
+ var stateMonth = this.wiki.getTiddlerText(MONTH_STATE_TITLE,"");
+ if(isValidMonth(stateMonth)) {
+ return stateMonth;
+ }
+
+ var now = new Date();
+ return monthKey(now.getFullYear(),now.getMonth() + 1);
+};
+
+JournalCalendarWidget.prototype.getCurrentView = function() {
+ if(this.view === "month" || this.view === "all") {
+ return this.view;
+ }
+ return this.wiki.getTiddlerText(VIEW_STATE_TITLE,"month") === "all" ? "all" : "month";
+};
+
+JournalCalendarWidget.prototype.setState = function(title,value) {
+ this.wiki.addTiddler({
+ title: title,
+ text: value
+ });
+};
+
+JournalCalendarWidget.prototype.getMonthJournals = function(value,byDate) {
+ return Object.keys(byDate).filter(function(date) {
+ return date.substring(0,7) === value;
+ }).sort().map(function(date) {
+ return {
+ date: date,
+ title: byDate[date].title,
+ tags: byDate[date].tags
+ };
+ });
+};
+
+JournalCalendarWidget.prototype.createButton = function(label,className,handler) {
+ var button = this.document.createElement("button");
+ button.setAttribute("type","button");
+ button.className = className;
+ button.textContent = label;
+ button.addEventListener("click",handler);
+ return button;
+};
+
+JournalCalendarWidget.prototype.renderOpenMonthButton = function(value,journals) {
+ if(!journals.length) {
+ return null;
+ }
+
+ var self = this,
+ button = this.createButton("展开本月日志(" + journals.length + ")","tc-btn-invisible tc-journal-calendar-expand",function(event) {
+ self.openMonthJournals(journals,event.currentTarget,event);
+ });
+ button.title = "打开" + formatMonth(value) + "的全部日志";
+ button.setAttribute("aria-label","打开" + formatMonth(value) + "的全部日志,共" + journals.length + "篇");
+ return button;
+};
+
+JournalCalendarWidget.prototype.renderViewSwitcher = function(currentView) {
+ if(this.view !== "switchable") {
+ return null;
+ }
+
+ var self = this,
+ controls = this.document.createElement("div");
+ controls.className = "tc-journal-calendar-view-switcher";
+
+ controls.appendChild(this.createButton("单月视图","tc-btn-invisible tc-journal-calendar-switch" + (currentView === "month" ? " tc-selected" : ""),function() {
+ self.setState(VIEW_STATE_TITLE,"month");
+ }));
+ controls.appendChild(this.createButton("全部月份","tc-btn-invisible tc-journal-calendar-switch" + (currentView === "all" ? " tc-selected" : ""),function() {
+ self.setState(VIEW_STATE_TITLE,"all");
+ }));
+ return controls;
+};
+
+JournalCalendarWidget.prototype.renderMonthControls = function(selectedMonth) {
+ if(isValidMonth(this.month)) {
+ return null;
+ }
+
+ var self = this,
+ selected = parseMonth(selectedMonth),
+ controls = this.document.createElement("div");
+ controls.className = "tc-journal-calendar-month-controls";
+
+ function setOffset(offset) {
+ var date = new Date(selected.year,selected.month - 1 + offset,1);
+ self.setState(MONTH_STATE_TITLE,monthKey(date.getFullYear(),date.getMonth() + 1));
+ }
+
+ controls.appendChild(this.createButton("上个月","tc-btn-invisible tc-journal-calendar-nav",function() {
+ setOffset(-1);
+ }));
+ controls.appendChild(this.createButton("本月","tc-btn-invisible tc-journal-calendar-nav",function() {
+ var now = new Date();
+ self.setState(MONTH_STATE_TITLE,monthKey(now.getFullYear(),now.getMonth() + 1));
+ }));
+ controls.appendChild(this.createButton("下个月","tc-btn-invisible tc-journal-calendar-nav",function() {
+ setOffset(1);
+ }));
+ return controls;
+};
+
+JournalCalendarWidget.prototype.renderToolbar = function(currentView,selectedMonth,byDate) {
+ var toolbar = this.document.createElement("div"),
+ monthSummary = this.document.createElement("div"),
+ monthTitle = this.document.createElement("h2"),
+ monthControls,
+ expandButton,
+ viewSwitcher;
+
+ toolbar.className = "tc-journal-calendar-toolbar";
+ monthSummary.className = "tc-journal-calendar-month-summary";
+ monthTitle.className = "tc-journal-calendar-current-month";
+ monthTitle.textContent = currentView === "all" ? "全部月份" : formatMonth(selectedMonth);
+ monthSummary.appendChild(monthTitle);
+
+ if(currentView === "month") {
+ monthControls = this.renderMonthControls(selectedMonth);
+ if(monthControls) {
+ monthSummary.appendChild(monthControls);
+ }
+ expandButton = this.renderOpenMonthButton(selectedMonth,this.getMonthJournals(selectedMonth,byDate));
+ if(expandButton) {
+ monthSummary.appendChild(expandButton);
+ }
+ }
+ toolbar.appendChild(monthSummary);
+
+ viewSwitcher = this.renderViewSwitcher(currentView);
+ if(viewSwitcher) {
+ toolbar.appendChild(viewSwitcher);
+ }
+ this.containerNode.appendChild(toolbar);
+};
+
+JournalCalendarWidget.prototype.navigateTo = function(title,node,event) {
+ var bounds = node.getBoundingClientRect();
+ this.dispatchEvent({
+ type: "tm-navigate",
+ navigateTo: title,
+ navigateFromTitle: this.getVariable("storyTiddler"),
+ navigateFromNode: this,
+ navigateFromClientRect: {
+ top: bounds.top,
+ left: bounds.left,
+ width: bounds.width,
+ right: bounds.right,
+ bottom: bounds.bottom,
+ height: bounds.height
+ },
+ navigateSuppressNavigation: event.metaKey || event.ctrlKey || event.button === 1
+ });
+};
+
+JournalCalendarWidget.prototype.openMonthJournals = function(journals,node,event) {
+ var self = this;
+ journals.forEach(function(journal) {
+ self.navigateTo(journal.title,node,event);
+ });
+};
+
+JournalCalendarWidget.prototype.renderMonth = function(value,byDate,showHeading) {
+ var self = this,
+ selected = parseMonth(value),
+ section = this.document.createElement("section"),
+ monthHeader = this.document.createElement("div"),
+ heading,
+ expandButton,
+ viewport = this.document.createElement("div"),
+ grid = this.document.createElement("div"),
+ journals = this.getMonthJournals(value,byDate),
+ weekdays = ["一","二","三","四","五","六","日"],
+ firstWeekday = (new Date(selected.year,selected.month - 1,1).getDay() + 6) % 7,
+ daysInMonth = new Date(selected.year,selected.month,0).getDate(),
+ today = new Date(),
+ index;
+
+ section.className = "tc-journal-calendar-month";
+ monthHeader.className = "tc-journal-calendar-month-header";
+ if(showHeading) {
+ heading = this.document.createElement("h2");
+ heading.className = "tc-journal-calendar-month-title";
+ heading.textContent = formatMonth(value);
+ monthHeader.appendChild(heading);
+ expandButton = this.renderOpenMonthButton(value,journals);
+ if(expandButton) {
+ monthHeader.appendChild(expandButton);
+ }
+ section.appendChild(monthHeader);
+ }
+ viewport.className = "tc-journal-calendar-viewport";
+ grid.className = "tc-journal-calendar-grid";
+
+ weekdays.forEach(function(weekday) {
+ var weekdayNode = self.document.createElement("div");
+ weekdayNode.className = "tc-journal-calendar-weekday";
+ weekdayNode.textContent = weekday;
+ grid.appendChild(weekdayNode);
+ });
+
+ for(index = 0; index < firstWeekday; index++) {
+ var blank = this.document.createElement("div");
+ blank.className = "tc-journal-calendar-day tc-journal-calendar-day-empty";
+ grid.appendChild(blank);
+ }
+
+ for(var day = 1; day <= daysInMonth; day++) {
+ var key = dateKey(selected.year,selected.month,day),
+ journal = byDate[key],
+ dayNode = this.document.createElement("div"),
+ numberNode;
+
+ dayNode.className = "tc-journal-calendar-day" + (journal ? " tc-journal-calendar-day-has-journal" : "");
+ if(selected.year === today.getFullYear() && selected.month === today.getMonth() + 1 && day === today.getDate()) {
+ dayNode.className += " tc-journal-calendar-day-today";
+ }
+
+ if(journal) {
+ numberNode = this.createButton(day,"tc-btn-invisible tc-journal-calendar-day-number tc-journal-calendar-day-link",function(title) {
+ return function(event) {
+ self.navigateTo(title,event.currentTarget,event);
+ };
+ }(journal.title));
+ numberNode.title = "打开日志:" + journal.title;
+ numberNode.setAttribute("aria-label","打开日志:" + journal.title);
+ } else {
+ numberNode = this.document.createElement("div");
+ numberNode.className = "tc-journal-calendar-day-number";
+ numberNode.textContent = day;
+ }
+ dayNode.appendChild(numberNode);
+
+ if(journal) {
+ journal.tags.forEach(function(tag) {
+ var tagButton = self.createButton(tag,"tc-btn-invisible tc-tag-label tc-journal-calendar-tag",function(event) {
+ self.navigateTo(tag,event.currentTarget,event);
+ });
+ tagButton.title = "打开标签:" + tag;
+ tagButton.setAttribute("aria-label","打开标签:" + tag);
+ dayNode.appendChild(tagButton);
+ });
+ }
+ grid.appendChild(dayNode);
+ }
+
+ viewport.appendChild(grid);
+ section.appendChild(viewport);
+ this.containerNode.appendChild(section);
+};
+
+JournalCalendarWidget.prototype.renderCalendar = function() {
+ var data = this.getJournalData(),
+ currentView = this.getCurrentView(),
+ selectedMonth = this.getCurrentMonth();
+
+ this.renderToolbar(currentView,selectedMonth,data.byDate);
+
+ if(currentView === "all") {
+ if(!data.months.length) {
+ var empty = this.document.createElement("div");
+ empty.className = "tc-journal-calendar-empty";
+ empty.textContent = "目前没有日志。";
+ this.containerNode.appendChild(empty);
+ return;
+ }
+ data.months.forEach(function(month) {
+ this.renderMonth(month,data.byDate,true);
+ },this);
+ return;
+ }
+
+ this.renderMonth(selectedMonth,data.byDate,false);
+};
+
+JournalCalendarWidget.prototype.refresh = function(changedTiddlers) {
+ var changedAttributes = this.computeAttributes();
+ if(Object.keys(changedAttributes).length || Object.keys(changedTiddlers).length) {
+ this.refreshSelf();
+ return true;
+ }
+ return false;
+};
+
+exports["journal-calendar"] = JournalCalendarWidget;
+
+})();
diff --git a/plugins/utilities/plugin.info b/plugins/utilities/plugin.info
new file mode 100644
index 0000000..0a2c117
--- /dev/null
+++ b/plugins/utilities/plugin.info
@@ -0,0 +1,11 @@
+{
+ "title": "$:/plugins/local/utilities",
+ "name": "Local Utilities",
+ "description": "Small browser-side utilities used by this wiki",
+ "author": "local",
+ "version": "1.1.1",
+ "core-version": ">=5.4.1",
+ "plugin-type": "plugin",
+ "dependents": ["$:/plugins/tiddlywiki/highlight"],
+ "list": "readme"
+}
diff --git a/plugins/utilities/readme.tid b/plugins/utilities/readme.tid
new file mode 100644
index 0000000..e2b35a9
--- /dev/null
+++ b/plugins/utilities/readme.tid
@@ -0,0 +1,8 @@
+title: $:/plugins/local/utilities/readme
+type: text/vnd.tiddlywiki
+
+! Local Utilities
+
+收纳当前条目目录、代码块复制、日志日历,以及 Caddyfile、JSON5 和 TiddlyWiki 的 Highlight 语言扩展。
+
+Highlight 语言扩展依赖官方插件:`$:/plugins/tiddlywiki/highlight`。