/* -*- coding: utf-8 -*- * * Copyright 2013 whiteleaf. All rights reserved. */ var Narou = (function() { "use strict"; var Narou = {}; var storage_cache = null; var storage = null; $.ajaxSetup({ cache: false // IE でキャッシュさせないため }); // jQuery はデフォルトだと dataTransfer オブジェクトをコピーしないので $.event.props.push("dataTransfer"); /************************************************************************* * ローカルストレージ *************************************************************************/ var Storage = Narou.Storage = function() { this.initialize(); }; $.extend(Storage.prototype, { storage_name: "Narou.rb_WEB_UI_saved", initialize: function() { if (!storage_cache) { storage_cache = this.load(); } this.objects = storage_cache; }, load: function() { var objects = localStorage.getItem(this.storage_name); return objects ? JSON.parse(objects) : {}; }, save: function() { localStorage.setItem(this.storage_name, JSON.stringify(this.objects)); }, get: function(key) { return this.objects[key]; }, set: function(key, value) { this.objects[key] = value; return this; }, }); storage = new Storage(); /************************************************************************* * ユーティリティ *************************************************************************/ $.extend(Narou, { registerCloseHandler: function(callback) { // Chrome, IEですぐにclickイベントをバインドすると、メニュー表示時の // クリックに反応してしまう(表示上のズレによって、クリック時のマウス // 座標上に対象オブジェクトが存在しないため)ので、イベント作成をほんの // 少し遅らせる setTimeout(function() { // 関係ないところをクリックした時に閉じる $(document).one("click", callback); }, 100); }, popupMenu: function(menu_id, pos, close_menu_handler) { var $menu = $(menu_id); var left = $(window).width() < pos.x - $(document).scrollLeft() + $menu.outerWidth() ? pos.x - $menu.outerWidth() : pos.x; var top = $(window).height() < pos.y - $(document).scrollTop() + $menu.outerHeight() ? pos.y - $menu.outerHeight() : pos.y; $menu.show().offset({ left: left, top: top }); Narou.registerCloseHandler(close_menu_handler); }, // http://qiita.com/osakanafish/items/c64fe8a34e7221e811d0 formatDate: function(date, format) { if (!format) format = 'YYYY-MM-DD hh:mm:ss.SSS'; format = format.replace(/YYYY/g, date.getFullYear()); format = format.replace(/MM/g, ('0' + (date.getMonth() + 1)).slice(-2)); format = format.replace(/DD/g, ('0' + date.getDate()).slice(-2)); format = format.replace(/hh/g, ('0' + date.getHours()).slice(-2)); format = format.replace(/mm/g, ('0' + date.getMinutes()).slice(-2)); format = format.replace(/ss/g, ('0' + date.getSeconds()).slice(-2)); if (format.match(/S/g)) { var milliSeconds = ('00' + date.getMilliseconds()).slice(-3); var length = format.match(/S/g).length; for (var i = 0; i < length; i++) format = format.replace(/S/, milliSeconds.substring(i, i + 1)); } return format; }, get_event_position: function(e) { if (e.type !== "touchstart") { return { x: e.pageX, y: e.pageY }; } else { return { x: e.originalEvent.touches[0].pageX, y: e.originalEvent.touches[0].pageY }; } }, noScroll: function() { $("html, body").css("overflow", "hidden"); }, allowScroll: function() { $("html, body").css("overflow", ""); }, }); /************************************************************************* * Push 通知管理 *************************************************************************/ var Notification = Narou.Notification = function() { this.initialize(); }; Notification.instance = function() { if (!this.__instance) { this.__instance = new this; } return this.__instance; }; $.extend(Notification.prototype, { RETRY_LIMIT: 5, RETRY_WAIT: 2000, initialize: function() { this.events = {}; this.retry_count = this.RETRY_LIMIT; this.connect(); }, connect: function() { if (this.connection) return; var self = this; this.connected = false; var connection = window.c = this.connection = new WebSocket(this.create_ws_uri()); connection.onopen = function() { self.connected = true; self.trigger("console.clear"); self.retry_count = self.RETRY_LIMIT; // 接続出来たらリトライカウント回復 }; connection.onclose = function() { self.connection = null; // PCのスリープ等でコネクションが切れた場合に再接続する if (self.retry_count-- > 0) { setTimeout(function() { self.connected = false; self.connect(); }, self.RETRY_WAIT); } }; connection.onmessage = function(e) { if (e && e.data) { self.onmessage(JSON.parse(e.data)); } }; }, create_ws_uri: function() { var host = location.hostname, port = location.port; return "ws://" + host + ":" + (parseInt(port) + 1) + "/"; }, onmessage: function(data) { var self = this; $.each(data, function(event, value) { self.trigger(event, value); }); }, on: function(event, block, once) { if (typeof block !== "function") { $.error("need a function"); } var stack = this.events[event] || []; stack.push([block, once]); this.events[event] = stack; }, one: function(event, block) { this.on(event, block, true); }, trigger: function(event, data) { var self = this; var stack = this.events[event] || []; this.events[event] = _.reject(stack, function(pair) { var block = pair[0], once = pair[1]; block.call(self, data); return once; }); }, send: function(json) { this.connection.send(JSON.stringify(json)); }, }); /************************************************************************* * 個別メニュー *************************************************************************/ var ContextMenu = Narou.ContextMenu = function(action, tag) { this.action = action; this.notification = Notification.instance(); this.tag = tag; this.closed = true; this.initializeConsoleDialog(); this.initializeDiffListEvent(); this.initializeMenu(); }; $.extend(ContextMenu.prototype, { open: function(target_id, pos, callback) { var self = this; this.target_id = target_id; if (!this.closed) { // メニューを開いた状態で直接ボタンを押した場合に一旦閉じるイベントを起こさせる this.close(); } this.closed = false; var caller = function() { if (typeof callback === "function") callback(); }; $(document).one("show.bs.dropdown", function() { self.object.hide(); self.closed = true; caller(); }); Narou.popupMenu(this.object, pos, function() { self.object.hide(); self.closed = true; caller(); }); }, close: function() { $(document).trigger("click"); this.closed = true; }, save: function(text) { this.text = text; storage.set("context_menu_text", text); storage.save(); this.object.remove(); this.initializeMenu(); }, openConsoleDialog: function(callback) { if (typeof callback !== "function") return; var $console_dialog = $("#console-dialog"); $console_dialog.one("shown.bs.modal", callback); $(document).one("cancel.narou.remove", function() { $console_dialog.modal("hide"); }); this.console.clear(); $console_dialog.modal(); }, initializeConsoleDialog: function() { this.console = new Narou.Console({ restore: false, buttons: false, id: "#each-console" }); }, openSelectDiffListDialog: function(target_id) { $.get("/api/diff_list", { target: target_id }, function(html) { var diff_modal = bootbox.dialog({ title: "表示したい差分を選択して下さい", message: html, backdrop: true, className: "diff-list-modal", buttons: { clear: { label: "差分を消去", className: "btn-danger", callback: function() { bootbox.confirm("本当に消去してよろしいですか?", function(result) { if (result) { $.post("/api/diff_clean", { target: target_id }); } diff_modal.modal("hide"); }); return false; // 親モーダルはこの時点では閉じさせない } }, main: { label: "閉じる", className: "btn-default" }, } }); }); }, initializeDiffListEvent: function() { $(document).on("click", ".diff-list-container .list .item", function() { var target = $(this).parent().data("diffTarget"); var number = $(this).data("diffItemNumber"); $.post("/api/diff", { ids: [ target ], number: number }); bootbox.hideAll(); }); }, initializeMenu: function() { this.initializeMenuObject(); this.initializeMenuEvents(); $("body").append(this.object); }, initializeMenuObject: function(text) { this.text = text || storage.get("context_menu_text") || this.createDefaultMenuText(); this.object = this.createMenuObject(this.text); }, createMenuObject: function(text) { var object = $('<ul class="context-menu dropdown-menu" role="menu">'); _.each(text.split("\n"), function(line) { var splited = line.split("<>"); var label = splited[0], command = splited[1]; var child; if (!command) return; if (command === "divider") child = $('<li class="divider">'); else child = $(sprintf('<li class="context-menu-%s"><a href="#">%s</a>', command, label)); object.append(child); }); return object; }, _default_commands: [ "setting", "diff", "edit_tag", "freeze_toggle", "update", "send", "remove", "convert", "inspect", "folder", "backup" ], items: [ { label: "――――――――(区切り)", command: "divider" }, { label: "小説の変換設定", command: "setting" }, { label: "差分を表示", command: "diff" }, { label: "タグを編集", command: "edit_tag" }, { label: "凍結 or 解凍", command: "freeze_toggle" }, { label: "更新", command: "update" }, { label: "凍結済みでも更新", command: "update_force" }, { label: "送信", command: "send" }, { label: "削除", command: "remove" }, { label: "変換", command: "convert" }, { label: "調査状況ログを表示", command: "inspect" }, { label: "保存フォルダを開く", command: "folder" }, { label: "バックアップを作成", command: "backup" }, { label: "再ダウンロード", command: "download_force" }, ], events: { setting: function() { var setting_page_path = "/novels/" + this.target_id + "/setting"; if (storage.get("open_new_tab_setting_pages")) { window.open(setting_page_path); } else { location.href = setting_page_path; } }, update: function() { this.openConsoleDialog(function() { this.action.update(this.target_id); }.bind(this)); }, update_force: function() { this.openConsoleDialog(function() { this.action.updateForce(this.target_id); }.bind(this)); }, send: function() { this.openConsoleDialog(function() { this.action.send(this.target_id); }.bind(this)); }, freeze_toggle: function() { this.action.freeze(this.target_id); }, remove: function() { this.openConsoleDialog(function() { this.action.remove(this.target_id); }.bind(this)); }, edit_tag: function() { this.tag.openEditor(this.target_id); }, convert: function() { this.openConsoleDialog(function() { this.action.convert(this.target_id); }.bind(this)); }, diff: function() { this.openSelectDiffListDialog(this.target_id); }, inspect: function() { this.openConsoleDialog(function() { this.action.inspect(this.target_id); }.bind(this)); }, folder: function() { this.action.folder(this.target_id); }, backup: function() { this.openConsoleDialog(function() { this.action.backup(this.target_id); }.bind(this)); }, download_force: function() { this.openConsoleDialog(function() { this.action.downloadForce(this.target_id); }.bind(this)); }, }, initializeMenuEvents: function() { var object = this.object; var self = this; _.each(this.events, function(fn, command) { object.find(".context-menu-" + command).on("click", function(e) { e.preventDefault(); if (self.action) fn.call(self); }); }); }, findItem: function(command) { return _.find(this.items, function(item) { return item.command === command; }); }, createDefaultMenuText: function() { var self = this; var menu_text_lines = []; _.each(this._default_commands, function(command) { var item = self.findItem(command); if (!item) $.error("invalid command(" + command + ")"); menu_text_lines.push(item.label + "<>" + command); }); return menu_text_lines.join("\n"); }, }); /************************************************************************* * アクション *************************************************************************/ var Action = Narou.Action = function(table) { this.table = table; }; $.extend(Action.prototype, { _getSelectedIds: function(args) { if (typeof args !== "undefined" && args.length > 0) { return Array.prototype.slice.call(args); } var ids = []; $.each(this.table.rows(".selected").data(), function(i, val) { ids.push(val.id); }); return ids; }, selectAll: function() { this.table.$("tr").addClass("selected"); this.table.fireChangeSelect(); }, selectView: function() { $("#novel-list tbody tr").addClass("selected"); this.table.fireChangeSelect(); }, selectClear: function() { this.table.$("tr.selected").removeClass("selected"); this.table.fireChangeSelect(); }, download: function() { var urls = []; if (typeof arguments !== "undefined" && arguments.length > 0) { urls = Array.prototype.slice.call(arguments); } if (urls.length == 0) { $.get("/partial/download_form") .done(function(html) { var download_modal = bootbox.dialog({ title: "ダウンロードする小説のURL、もしくはNコードを入力(複数可)", message: html, backdrop: true, buttons: { cancel: { label: "キャンセル", className: "btn-default" }, main: { label: "ダウンロード", className: "btn-primary", callback: function() { $("#download-link-submit").click(); } } } }); download_modal.one("shown.bs.modal", function () { $("#download-input").focus(); }); }); } else { $.post("/api/download", { targets: urls }); } }, downloadForce: function() { var ids = this._getSelectedIds(arguments); if (ids.length === 0) return; $.post("/api/download_force", { "ids": ids }); }, update: function() { var ids = this._getSelectedIds(arguments); $.post("/api/update", { "ids": ids }); }, updateView: function() { var self = this; var ids = []; $("#novel-list tbody tr").each(function(i, tr) { var data = self.table.row(tr).data(); if (data) { ids.push(data.id); } }); if (ids.length > 0) { $.post("/api/update", { "ids": ids }); } }, _restoreUpdateGeneralLastupChecked: function() { return storage.get("update_general_lastup_checked") || { narou: true, other: false, updateModified: false }; }, _storeUpdateGeneralLastupChecked: function(checkedObject) { storage.set("update_general_lastup_checked", checkedObject); storage.save(); }, _updateGeneralLastupHtmlRenderer: _.template( (function () {/* <p>凍結済みを除く各小説の最新話掲載日のみを確認して反映させます。<br> なろうAPIを使える小説はAPIを使用して一括で確認するので、高速に掲載日を調べることが出来ます。その他の小説はひとつずつ確認するので時間がかかります。</p> <ul><li>検索除外されている小説はAPIでは確認出来ません。</li> <li>最新話掲載日は通常のUpdateでも更新されています。</li> <li>更新が確認された小説には<code>modified</code>タグが自動で追加されます。Updateすることで削除されます。(ジャンルなどのNarou.rbが見ていない情報だけが変更されても更新されたとみなされます)</li> <li>“実際に小説のデータが修正されてからなろうAPIに反映されるまで平均5分程度(最大2時間)の誤差があります。”<span class="text-muted">(<a href="http://dev.syosetu.com/man/api/" target="_blank">公式サイト</a>から引用)</span></li></ul> <p class="text-right"> <label><input type="checkbox" id="update-gl-narou" <% if (narou) { %>checked<% } %>> なろう小説をAPIで確認</label> <label><input type="checkbox" id="update-gl-other" <% if (other) { %>checked<% } %>> その他の小説を確認</label><br> <label><input type="checkbox" id="update-gl-and-update-modified" <% if (updateModified) { %>checked<% } %>> 確認後にmodifiedタグが付いた小説をUpdate</label> </p> */}).toString().replace(/(\n)/g, '').split('*')[1] ), updateGeneralLastup: function() { var self = this; var checkedObject = this._restoreUpdateGeneralLastupChecked(); bootbox.dialog({ title: "最新話掲載日の確認", message: this._updateGeneralLastupHtmlRenderer(checkedObject), backdrop: true, buttons: { cancel: { label: "キャンセル", className: "btn-default", }, main: { label: "確認する", className: "btn-primary", callback: function() { var glNarou = $("#update-gl-narou").is(":checked"); var glOther = $("#update-gl-other").is(":checked"); var isUpdateModified = $("#update-gl-and-update-modified").is(":checked"); self._storeUpdateGeneralLastupChecked({ narou: glNarou, other: glOther, updateModified: isUpdateModified }); if (!glNarou && !glOther) return; var option = glNarou && glOther && "all"; if (!option && glNarou) option = "narou"; if (!option && glOther) option = "other"; $.post("/api/update_general_lastup", { option: option, is_update_modified: isUpdateModified }); } }, } }); }, updateGeneralLastupNarou: function() { $.post("/api/update_general_lastup", { option: "narou", is_update_modified: false }); }, updateGeneralLastupOther: function() { $.post("/api/update_general_lastup", { option: "other", is_update_modified: false }); }, updateGeneralLastupUpdateModified: function() { $.post("/api/update_by_tag", { tags: ["modified"] }); }, updateForce: function() { var ids = this._getSelectedIds(arguments); $.post("/api/update", { ids: ids, force: true }); }, updateByTag: function() { UpdateByTagBox.open(); }, send: function() { var ids = this._getSelectedIds(arguments); $.post("/api/send", { "ids": ids }); }, freeze: function() { var ids = this._getSelectedIds(arguments); if (ids.length === 0) return; $.post("/api/freeze", { "ids": ids }); }, freezeOn: function() { var ids = this._getSelectedIds(arguments); if (ids.length === 0) return; $.post("/api/freeze_on", { "ids": ids }); }, freezeOff: function() { var ids = this._getSelectedIds(arguments); if (ids.length === 0) return; $.post("/api/freeze_off", { "ids": ids }); }, _removeConfirmDialog: function(title, ids, callback) { var message = ""; this.table.rows().data().each(function(data, idx) { if (ids.indexOf(data.id + "") !== -1) { message += "<li>" + data.title + "</li>"; } }); message = '<div style="max-height:300px;overflow:auto"><ul>' + message + '</ul></div>' + '<div class="checkbox pull-right"><label><input type="checkbox" id="check-remove-with-file">' + '保存フォルダ・ファイルも一緒に削除する</label></div><div class="clearfix"></div>'; bootbox.dialog({ title: title, message: message, buttons: { danger: { label: "削除する", className: "btn-danger", callback: function() { callback(true, $("#check-remove-with-file").prop("checked")); $(document).trigger("ok.narou.remove"); } }, main: { label: "キャンセル", className: "btn-default", callback: function() { callback(false, false); $(document).trigger("cancel.narou.remove"); } } } }); }, remove: function() { var ids = this._getSelectedIds(arguments); if (ids.length === 0) return; this._removeConfirmDialog("選択した小説を削除しますか?", ids, function(result, with_file) { if (!result) return; $.post("/api/remove", { ids: ids, with_file: with_file }); }); }, convert: function() { var ids = this._getSelectedIds(arguments); if (ids.length === 0) return; $.post("/api/convert", { "ids": ids }); }, diff: function() { var ids = this._getSelectedIds(arguments); if (ids.length === 0) return; $.post("/api/diff", { "ids": ids }); }, inspect: function() { var ids = this._getSelectedIds(arguments); if (ids.length === 0) return; $.post("/api/inspect", { "ids": ids }); }, folder: function() { var ids = this._getSelectedIds(arguments); if (ids.length === 0) return; $.post("/api/folder", { "ids": ids }); }, backup: function() { var ids = this._getSelectedIds(arguments); if (ids.length === 0) return; $.post("/api/backup", { "ids": ids }); }, settingBurn: function() { var ids = this._getSelectedIds(arguments); if (ids.length === 0) return; $.post("/api/setting_burn", { "ids": ids }); }, csvImport: function() { $.get("/partial/csv_import", function(html) { var box = bootbox.dialog({ title: "CSVファイルからのインポート", message: html, buttons: { cancel: { label: "キャンセル", className: "btn-default", }, main: { label: "インポート", className: "btn-primary", callback: function() { $("#csv-import-files").click(); } } } }); }); }, rebootDialog: function() { var self = this; bootbox.dialog({ title: '<span class="glyphicon glyphicon-refresh"></span> 再起動', message: "<p>Narou.rb WEB UI サーバを再起動します。<br>" + "バージョンを更新してある場合は最新バージョンで立ち上がります。</p>" + "<p class=text-danger>アップデート中や変換中の小説がある場合は中断されます。<br>" + "終わったかどうか確認しましょう。</p>", backdrop: true, buttons: { danger: { label: "再起動", className: "btn-danger", callback: function() { self.reboot(); } }, main: { label: "キャンセル", className: "btn-default", } } }); }, reboot: function() { $.post("/reboot", function(data) { bootbox.hideAll(); bootbox.dialog({ title: "", closeButton: false, message: data }); }); }, checkUpdatedSystem: function(funcs) { $.post("/check_already_update_system", function(data) { if (data.result) { $.post("/gem_update_last_log", function(log) { funcs.already_updated(log); }); } else { funcs.not_updated(); } }); }, updateSystem: function(callback) { var notification = Notification.instance(); notification.one("server.update.success", function(log) { callback("success", log); }); notification.one("server.update.nothing", function(log) { callback("nothing", log); }); notification.one("server.update.failure", function(log) { callback("failure", log); }); $.post("/update_system"); }, eject: function() { $.post("/api/eject", { enqueue: true }); }, ejectNow: function() { $.post("/api/eject", { enqueue: false }); }, }); /************************************************************************* * コンソール *************************************************************************/ var Console = Narou.Console = function(options) { this.options = $.extend({ restore: true, // コンソールの大きさを復元・保存するか buttons: true, // 拡大縮小等のコントロールボタンを使用するか id: "#console", // コンソールのID名 buttons_id: "#console-buttons" // コントロールボタンを格納している要素のID名 }, options); this.initialize(); }; $.extend(Console.prototype, { animate_duration: 200, initialize: function() { this.notification = Notification.instance(); this.last_char_was_return = true; this.console = $(this.options.id); this.init_scrollbar(); if (this.options.buttons) this.init_buttons(); this.init_events(); this.init_notification(); if (this.options.restore) this.restore_console_shape(); }, init_scrollbar: function() { this.console.perfectScrollbar({ wheelspeed: 80, suppressScrollX: true, minScrollbarLength: 20, }); this.original_height = this.console.height(); }, init_buttons: function() { var self = this; this.console.css("min-height", this.original_height); $(this.options.buttons_id + " .console-expand").on("click", function(e) { self.expand_console(); }); $(this.options.buttons_id + " .console-trash").on("click", function(e) { self.trash_console(); }); $(this.options.buttons_id + " .queue-cancel").on("click", function(e) { $.post("/api/cancel"); }); }, init_events: function() { this.manage_resize_event(); var self = this; this.console.on("resize", function() { if (!self.options.restore) return; var data = { height: self.console.height(), expanded: self.console.hasClass("expanded") }; storage.set("console", data); storage.save(); }); this.init_events_progressbar(); this.notification.on("console.clear", function() { self.clear(); }); }, init_events_progressbar: function() { var self = this; var $progress = null; var createProgressHtml = function(percent) { return '<div class="progress"><div class="progress-bar progress-bar-success progress-bar-striped active" role="progressbar" style="width:' + percent + '%"></div></div>'; }; var initializeProgressbar = function(percent) { self.puts(createProgressHtml(percent)); $progress = self.console.find(".progress > div"); }; var setProgressValue = function(step) { $progress.width(step + "%"); }; this.notification.on("progressbar.init", function() { initializeProgressbar(0); }); this.notification.on("progressbar.step", function(value) { if ($progress) { setProgressValue(value); } else { initializeProgressbar(value); } }); this.notification.on("progressbar.clear", function() { if (!$progress) return; $progress = null; $(".progress").parent("div").remove(); }); }, init_notification: function() { var self = this; this.notification.on("echo", function(message) { self.print(message); }); }, manage_resize_event: function() { /* var con = this.console; con.data('x', con.outerWidth()); con.data('y', con.outerHeight()); $(document).on("mouseup", function () { if (con.outerWidth() != con.data('x') || con.outerHeight() != con.data('y')) { con.trigger("resize"); } con.data('x', con.outerWidth()); con.data('y', con.outerHeight()); }); */ }, restore_console_shape: function() { var data = storage.get("console"); if (data) { this.console.height(data.height); if (data.expanded) this.toggle_expanded(); } }, scroll_to_bottom: function() { var con = this.console; con.scrollTop(con.prop("scrollHeight")); }, position_is_bottom: function() { var con = this.console; return (con.scrollTop() >= con.prop("scrollHeight") - con.outerHeight() - 10); }, print: function(str) { var self = this; var con = this.console; var last_char = str[str.length - 1]; var lines = str.split("\n"); var was_bottom = this.position_is_bottom(); var last_char_is_return = last_char === "\n"; if (last_char_is_return) { lines = lines.slice(0, -1); } if (!this.last_char_was_return) { var last = con.find("div.console-line:last-child"); var char = lines.pop(); if (char) last.append(char); } $.each(lines, function() { con.append("<div class=console-line>" + this); }); con.perfectScrollbar("update"); // 表示する段階で最下部までスクロールしてあった場合はスクロールする if (was_bottom) { this.scroll_to_bottom(); } this.last_char_was_return = last_char_is_return; }, puts: function(str) { if (str[str.length - 1] !== "\n") { this.print(str.concat("\n")); } else { this.print(str); } }, expand_console: function() { var self = this; var calced_height; if (this.console.hasClass("expanded")) { calced_height = this.original_height; } else { var top = this.console.offset().top; calced_height = ($(window).height() - top) * 0.8; } var was_bottom = this.position_is_bottom(); this.console.stop().animate({ height: calced_height }, this.animate_duration, // after do function() { if (was_bottom) self.scroll_to_bottom(); self.console.trigger("resize"); self.console.perfectScrollbar("update"); }); this.toggle_expanded(); }, toggle_expanded: function() { this.console.toggleClass("expanded"); $(".console-expand > span").toggleClass("hide"); }, // コンソールのログを削除すると同時に、サーバの履歴も削除する trash_console: function() { this.clear(); $.post("/api/clear_history"); }, // コンソールの内容をクリア clear: function() { this.console.find("div.console-line").remove(); } }); /************************************************************************* * 検索機能 * dataTables の検索に、タグ検索機能を追加する *************************************************************************/ var Search = Narou.Search = function(table) { this.initialize(table); }; Search.get = function(table) { if (!this.__instance) { this.__instance = new this(table); } return this.__instance; }; $.extend(Search.prototype, { initialize: function(table) { this.table = table; this.myfilter = $("#myFilter"); this.filter_tags = []; this.exclusion_tags = []; // 除外タグ this.myfilter_clear = $("#myFilter-clear"); this.myfilter_clear_timer_id = null; this.initializeEvents(); }, initializeEvents: function() { var self = this; // フィルターのフックAPIでタグを検索する $.fn.dataTable.ext.search.push(function (settings, data, dataIndex) { if (_.isEmpty(self.filter_tags) && _.isEmpty(self.exclusion_tags)) return true; var tags = self.table.row(dataIndex).data().tags; var matched = true; // 一致タグ検索 _.each(self.filter_tags, function(tag_name) { matched = !!tags.match(new RegExp('data-tag="' + tag_name + '"')); return matched; }); if (matched) { // 除外タグ検索 _.each(self.exclusion_tags, function(tag_name) { matched = !tags.match(new RegExp('data-tag="' + tag_name + '"')); return matched; }); } return matched; }); // カスタムフィルタボックスの変更イベント this.myfilter.on("keyup", function() { clearTimeout(self.myfilter_clear_timer_id); self.myfilter_clear_timer_id = setTimeout(function() { self.search(); }, 300); self.myFilterClearToggleVisiblity(); }); // フィルタのリセットボタン this.myfilter_clear.on("click", function() { self.myfilter.val(""); self.myfilter_clear.hide(); self.search(); }); // 検索アイコンも反応するようにしておく(コピペとかで反応しない場合用) $("#myFilter-search-icon").on("click", function() { self.search(); self.myFilterClearToggleVisiblity(); }); }, appendTagToFilter: function(tag_name, exclude) { if (_.includes(this.filter_tags, tag_name) || _.includes(this.exclusion_tags, tag_name)) { // すでにフィルターに入力済みなら何もしない return; } if (tag_name) { var str = sprintf( "%(current)s %(exclude_flag)stag:%(tag_name)s", { current: _.trimEnd(this.myfilter.val()), exclude_flag: exclude ? "^" : "", tag_name: tag_name }); this.myfilter.val(_.trimStart(str) + " "); } else { this.removeAllTagsByFilter(); } this.search(); this.myFilterClearToggleVisiblity(); }, removeAllTagsByFilter: function() { var normal_words = this.splitFilter().normal; this.myfilter.val(normal_words.join(" ")); this.clearTagCaches(); }, splitFilter: function(string) { string = string || this.myfilter.val().trim(); var result = { filter_tags: [], exclusion_tags: [] }; var words = string.split(/\s+/); result.normal = _.filter(words, function(word) { if (word.match(/^([-^]?)tag:(.+)$/i)) { var exclude_flag = !!RegExp.$1; var tag_name = RegExp.$2; if (exclude_flag) result.exclusion_tags.push(tag_name); else result.filter_tags.push(tag_name); return false; } else { return true; } }.bind(this)); return result; }, clearTagCaches: function() { this.filter_tags.length = 0; this.exclusion_tags.length = 0; }, searchSync: function() { this.search(true); }, _flatPush: function(target, array) { return target.push.apply(target, array); }, _searchFn: function() { var filter_string = this.myfilter.val(); var words = this.splitFilter(_.trim(filter_string)); var normal_words = words.normal; this.clearTagCaches(); this._flatPush(this.filter_tags, words.filter_tags); this._flatPush(this.exclusion_tags, words.exclusion_tags); // タグ以外の単語で通常検索し、タグ部分はフックAPIでフィルターする var normal_words_string = normal_words.join(" "); if (this.before_normal_words_string !== normal_words_string) { // 通常の検索は一度 table.search() を実行すれば維持されるので、 // 検索語が変化しないかぎりは一度の実行でいい this.table.search(normal_words_string); this.before_normal_words_string = normal_words_string; } // table.draw() を実行することにより、table.search() のフックAPIが実行される this.table.draw(); // dataTables は search() に渡された文字列しか自動保存しないので、タグ含めて自前で保存 storage.set("filter_string", filter_string).save(); }, search: function(sync) { if (sync) this._searchFn(); else setTimeout(this._searchFn.bind(this), 10); }, myFilterClearToggleVisiblity: function() { if (this.myfilter.val() === "") { this.myfilter_clear.hide(); } else { this.myfilter_clear.show(); } }, }); /************************************************************************* * タグ機能 *************************************************************************/ var Tag = Narou.Tag = function(table) { this.table = table; this.search = Search.get(table); this.registerEvents($("#tag-list-canvas")); this.registerEvents($("#novel-list tbody")); this.updateCanvas(); }; $.extend(Tag.prototype, { updateCanvas: function() { var $canvas = $("#tag-list-canvas"); $.get("/api/tag_list", function(source) { $canvas.html(source); }); }, registerEvents: function($target, stop_bubbling) { var self = this; if (typeof stop_bubbling === "undefined") stop_bubbling = true; var args = { stop_bubbling: stop_bubbling }; $target .on("click", ".tag", args, function(e) { if (e.data.stop_bubbling) e.stopPropagation(); var tag_name = String($(this).data("tag")); self.search.appendTagToFilter(tag_name, e.altKey); self.table.$("[data-toggle=tooltip]").tooltip("hide"); }) .on("mousedown", ".tag", args, function(e) { // 範囲選択モードでもクリック出来るように if (e.data.stop_bubbling) e.stopPropagation(); }); }, openEditor: function() { var ids = Action.prototype._getSelectedIds.call(this, arguments); if (ids.length === 0) return; this._createEditorField(ids, function(field) { bootbox.dialog({ title: "タグの編集", message: field, buttons: { cancel: { label: "キャンセル", className: "btn-default", callback: function() { } }, main: { label: "適用", className: "btn-primary", callback: function() { var states = {}; var new_tag = $("#new-tag").val(); $("#tag-editor-field input[type=checkbox]").each(function(i, v) { states[$(v).data("tagname")] = $(v).data("checkState"); }); if (new_tag) { states[new_tag] = 2; } $.post("/api/edit_tag", { ids: ids, states: states }); } } } }); }); }, _createEditorField: function(ids, callback) { var field = $("<div id=tag-editor-field class=form-group>"); var self = this; var ids_count = ids.length; function calcState(count) { if (count === 0) { return 0; } else if (count < ids_count) { return 1; } else { return 2; } } $.get("/api/taginfo.json", { ids: ids }, function(taginfo) { _.each(taginfo, function(info) { var label = $('<label><input type="checkbox" data-tagname="' + info.tag.replace(/"/g, """) + '" data-default-checkstate=' + calcState(info.count) + '> ' + info.html + ' </label>'); field.append(label); }); var input = $('<div><input type="text" id="new-tag" placeholder="新規タグ" class="form-control"></div>' + '<div><small>(複数追加する場合は半角スペースで区切る)</small></div>'); field.append(input); self._registerEventsForEditorField(field); callback(field); }); }, _registerEventsForEditorField: function(field) { function setCheckState(element, state) { switch (state) { case 0: element.prop("indeterminate", false); element.prop("checked", false); break; case 1: if (element.data("defaultCheckstate") == 1) { element.prop("indeterminate", true); element.prop("checked", false); break; } state++; case 2: element.prop("indeterminate", false); element.prop("checked", true); break; } element.data("checkState", state); } field.find("input[type=checkbox]") .on("click", function(e) { var elm = $(e.target); var next_state = (elm.data("checkState") + 1) % 3; setCheckState(elm, next_state); }) .each(function(i, v) { var elm = $(v); setCheckState(elm, elm.data("defaultCheckstate")); }); }, }); /************************************************************************* * タグの色選択機能 *************************************************************************/ var SelectColorMenu = Narou.SelectColorMenu = (function() { this.initializeEvents(); this.initializeMenuEvents(); this.$menu = $("#select-color-menu"); }); $.extend(SelectColorMenu.prototype, { open: function(target_tag, pos, callback) { var self = this; this.target_tag = target_tag; var caller = function() { if (typeof callback === "function") callback(); }; $(document).one("hide.bs.dropdown", function() { self.$menu.hide(); }); Narou.popupMenu("#select-color-menu", pos, function() { self.$menu.hide(); caller(); }); }, initializeEvents: function() { var self = this; $(document).on("click", ".select-color-button", function(e) { e.stopPropagation(); var $this = $(this); var pos = { x: $this.offset().left + $this.width() + 10, y: $this.offset().top - 20 } self.open($(this).data("targetTag"), pos, function() { }); }); }, initializeMenuEvents: function() { var self = this; var colors = ["green", "yellow", "blue", "magenta", "cyan", "red", "white"]; $.each(colors, function(i, color) { $("#select-color-menu-" + color).on("click", function(e) { e.preventDefault(); e.stopPropagation(); $.post("/api/change_tag_color", { tag: self.target_tag, color: color }); self.$menu.hide(); }); }); }, }); /************************************************************************* * タグを指定して更新ダイアログ *************************************************************************/ var UpdateByTagBox = { open: function() { this._createEditorField(function(field) { bootbox.dialog({ title: "タグを指定してアップデート", message: field, buttons: { cancel: { label: "キャンセル", className: "btn-default", }, main: { label: "更新する", className: "btn-primary", callback: function() { var tags = []; var exclusion_tags = []; $("#update-by-tag-field input[type=checkbox]").each(function(i, v) { if ($(v).is(":checked")) { var tagname = $(v).data("tagname"); var exclusion_tagname = $(v).data("exclusionTagname"); if (tagname) tags.push(tagname); else if (exclusion_tagname) exclusion_tags.push(exclusion_tagname); } }); $.post("/api/update_by_tag", { tags: tags, exclusion_tags: exclusion_tags }); } } } }); }); }, _createEditorField: function(callback) { var field = $("<div id=update-by-tag-field class=form-group>"); $.get("/api/taginfo.json", { ids: [0], with_exclusion: true }, function(taginfo) { var div = $("<div><p>更新したい小説のタグにチェックを入れて下さい。</p>"); _.each(taginfo, function(info) { var label = $('<label><input type="checkbox" data-tagname="' + info.tag.replace(/"/g, """) + '"> ' + info.html + ' </label>'); div.append(label); }); field.append(div); field.append("<p><br></p>"); div = $("<div><p>除外タグを指定して更新したいものがあればチェックを入れて下さい。</p>"); _.each(taginfo, function(info) { var exclusion_label = $('<label><input type="checkbox" data-exclusion-tagname="' + info.tag.replace(/"/g, """) + '"> ' + info.exclusion_html + ' </label>'); div.append(exclusion_label); }); field.append(div); callback(field); }); }, }; /************************************************************************* * ドラッグ&ドロップ関係 *************************************************************************/ var DragDrop = Narou.DragDrop = (function() { this.initializeValidateUrlRegexpList(); }); $.extend(DragDrop.prototype, { initializeValidateUrlRegexpList: function() { if (typeof this.validate_url_regexp_list !== "undefined") return; var self = this; this.validate_url_regexp_list = []; $.ajax({ type: "GET", url: "/api/validate_url_regexp_list", dataType: "json", success: function(json) { $.each(json, function(i, value) { self.validate_url_regexp_list.push(value); }); } }); }, isValidNovelUrl: function(url) { var result = false; $.each(this.validate_url_regexp_list, function(i, regexp) { if (url.match(regexp)) { result = true; return false; // break } }); return result; }, getLinkText: function(event, callback) { var self = this; var data_transfer = event.dataTransfer; $.each(data_transfer.types, function(i, type) { if (type === "Files") { // ショートカットファイルがドラッグされた場合は中身からリンクを抜き出す $.each(data_transfer.files, function(i, file) { if (!self.isSupportFile(file.name)) return; var reader = new FileReader; reader.onload = function(e) { var link = self.parseLink(file.name, reader.result); if (link) callback(link); }; reader.readAsText(file); }); } else { // TextはIE用 if (type === "text/plain" || type === "Text") { var link = data_transfer.getData(type); if (link.length > 0) { callback(link); } } } }); return; }, isSupportFile: function(filename) { return !!filename.match(/\.(?:url|webloc)$/i); }, parseLink: function(filename, text) { filename.toLowerCase().match(/\.(url|webloc)$/); var link = null; switch (RegExp.$1) { case "url": link = this.parseLinkDotURL(text); break; case "webloc": link = this.parseLinkDotWebloc(text); break; } return link; }, // OSX で使われる .webloc 形式のショートカット parseLinkDotWebloc: function(text) { if (text.match(/<string>(.+?)<\/string>/i)) { return RegExp.$1; } return null; }, // Windows で使われる .URL 形式のショートカット parseLinkDotURL: function(text) { if (text.match(/URL=(.+)/i)) { return RegExp.$1; } return null; }, }); /************************************************************************* * 自動保存・同期機能付きメモ帳 *************************************************************************/ var Notepad = Narou.Notepad = (function() { this.object_id = this.createObjectId(); }); Notepad.replace = function(id, options) { var notepad = new Notepad; notepad.replace(id, options); return notepad; }; $.extend(Notepad.prototype, { DEFAULTS: { autosave: true, readonly: false, synchronizing: true, // 別ウィンドウ同士で内容を同期するか rows: 20, // string or Deferred オブジェクト text: function() { return $.get("/api/notepad/read"); } }, renderer: _.template( '<div id="<%= container_id %>" class="notepad-container">' + '<textarea class="form-control" rows="<%- rows %>" ' + '<% if (readonly) { %>readonly<% } %>' + '><%- text %></textarea>' + '<span class="notepad-icon glyphicon glyphicon-ok text-success hide"></span>' + '</div>' ), replace: function(id, options) { var opt = _.merge({}, this.DEFAULTS, options); this.id = id; this.autosave = opt.autosave; this.readonly = opt.readonly; this.synchronizing = opt.synchronizing; this.rows = opt.rows; this.text = opt.text; this.createElements(); }, save: function(textarea) { var self = this; textarea._old_value = textarea.value; return $.post("/api/notepad/save", { text: textarea.value, object_id: this.object_id }) .done(function() { self.activeOkIcon(); }); }, createObjectId: function() { return String(_.now()) + _.random(0, 10000); }, createElements: function() { var self = this; var text = this.text; var render = function(stringified_text) { var rendered_html = self.renderer({ container_id: self.containerId(), readonly: self.readonly, rows: self.rows, text: stringified_text }); var elm = $("#" + self.id).html(rendered_html); var textarea = elm.find("textarea"); if (self.autosave && !self.readonly) { self.attachAutoSaveEvents(textarea); } if (self.synchronizing) { self.attachSynchronizingEvents(textarea); } }; if (typeof text == "function") { text() .done(function(stringified_text) { render(stringified_text); }) .fail(function() { render(""); }); } else { render(text); } }, attachAutoSaveEvents: function(textarea) { var timer_id = null; var self = this; textarea .on("focus", function() { this._old_value = this.value; }) .on("blur", function() { if (this.value !== this._old_value) { self.save(this); } }) .on("keyup paste cut", function() { // paste, cut イベントは実行される「直前」に発生するので、 // 実際にテキストボックスに反映されるまでに少し待つ setTimeout(function() { clearTimeout(timer_id); timer_id = setTimeout(function(value, old_value) { if (value !== old_value) { self.save(this); } }.bind(this), 1000, this.value, this._old_value); }.bind(this), 10); }); }, activeOkIcon: function() { var icon = this.container().find(".notepad-icon"); icon .removeClass("hide") .show() .delay(2000) .fadeOut(1000); }, attachSynchronizingEvents: function(textarea) { var self = this; var notification = Notification.instance(); notification.on("notepad.change", function(data) { if (data.object_id == self.object_id) return; var dom = textarea[0]; if (dom.value != data.text) { dom.value = dom._old_value = data.text; } }); }, container: function() { return $("#" + this.containerId()); }, containerId: function() { return this.id + "_notepad"; }, }); /************************************************************************* * 埋め込みテンプレート変換 *************************************************************************/ Narou.Template = { // role="template" を探して、そのテンプレートを処理したあと // 同じ場所にレンダリング結果を埋め込む。 // 一回だけレンダリングすればいいもの向け replaceAll: function(hash) { $("[role=template]").each(function() { var renderer = _.template($(this).text()); $(this).after(renderer(hash)); }); } }; /************************************************************************* * フラッシュメッセージ *************************************************************************/ Narou.Flash = { renderer: _.template( '<div class="container">' + '<div class="fadeout-alert alert alert-<%= type %>">' + '<%= message %>' + '</div></div>' ), show: function(message, type) { var obj = $(Narou.Flash.renderer({ message: message, type: type || "success" })); $("body").append(obj); this.setEvents(obj); }, setEvents: function(object) { $(object) .delay(2000) .animate({ opacity: "hide" }, 1500) .queue(function(next) { $(this).remove(); next(); }); } }; return Narou; })();