} tags
+ */
+ static fromIterable(tags) {
+ return new this(Array.from(tags));
+ }
+
+ /**
+ * 空のタグ集合を返す
+ */
+ static empty() {
+ return new this([]);
+ }
+
+ /**
+ * @param {Tag[]} tags
+ */
+ constructor(tags) {
+ this.map = new Map(tags.map((tag) => [tag.text, tag]));
+ }
+
+ /**
+ * 自身の持つタグの個数を返す
+ */
+ size() {
+ return this.map.size;
+ }
+
+ /**
+ * 自身が引数のタグを持っている時にtrueを返す
+ *
+ * @param {Tag} tag
+ */
+ has(tag) {
+ return tag instanceof Tag && this.map.has(tag.text);
+ }
+
+ /**
+ * 引数のタグの集合と自身が一致するならば、trueを返す
+ *
+ * @param {Tags} other
+ */
+ equals(other) {
+ return (
+ other.map.size === this.map.size &&
+ other.toArray().every((tag) => this.has(tag))
+ );
+ }
+
+ /**
+ * 保持しているタグの配列を返す
+ *
+ * @return {Tag[]}
+ */
+ toArray() {
+ return Array.from(this.map.values());
+ }
+
+ /**
+ * 引数と自身の和集合を返す
+ *
+ * @param {Tags} other
+ * @return {Tags}
+ */
+ union(other) {
+ const thisArray = this.toArray();
+ const otherArray = other.toArray();
+
+ return new Tags(thisArray.concat(otherArray));
+ }
+
+ /**
+ * 自身から引数を引いた差集合を返す
+ *
+ * @param {Tags} other
+ */
+ diff(other) {
+ const tags = this.toArray().filter((key) => !other.has(key));
+ return new Tags(tags);
+ }
+
+ /**
+ * 自身と引数の交差集合を返す
+ *
+ * @param {Tags} other
+ */
+ intersect(other) {
+ const tags = this.toArray().filter((key) => other.has(key));
+ return new Tags(tags);
+ }
+
+ /**
+ * 引数のタグを追加した新しいタグの集合を返す
+ *
+ * @param {Tag} tag
+ */
+ append(tag) {
+ return new Tags(this.toArray().concat([tag]));
+ }
+
+ /**
+ * 破壊的にタグを追加する
+ *
+ * @param {Tag} tag
+ * @return {Tags} 自身を返す
+ */
+ $append(tag) {
+ this.map.set(tag.text, tag);
+ return this;
+ }
+
+ /**
+ * タグを削除した新しいタグの集合を返す
+ *
+ * @param {Tag} tag
+ * @return {Tags} 新しいタグ
+ */
+ remove(tag) {
+ return new Tags(this.toArray().filter((e) => e.notEquals(tag)));
+ }
+
+ /**
+ * 破壊的にタグを削除する
+ *
+ * @param {Tag} tag
+ * @return {Tags} 自身を返す
+ */
+ $remove(tag) {
+ this.map.delete(tag.text);
+ return this;
+ }
+
+ isEmpty() {
+ return this.map.size === 0;
+ }
+}
diff --git a/src/domain/work.js b/src/domain/work.js
new file mode 100644
index 0000000..43cf15e
--- /dev/null
+++ b/src/domain/work.js
@@ -0,0 +1,17 @@
+/**
+ * @typedef {import('./tag.js').Tags} Tags
+ */
+
+/**
+ * 作品
+ */
+export class Work {
+ /**
+ * @param {string} title タイトル
+ * @param {Tags} tags 作品タグ
+ */
+ constructor(title, tags) {
+ this.title = title;
+ this.tags = tags;
+ }
+}
diff --git a/src/lib.js b/src/lib.js
new file mode 100644
index 0000000..e88017a
--- /dev/null
+++ b/src/lib.js
@@ -0,0 +1,26 @@
+/**
+ * @template T
+ */
+export class Copyable {
+ /**
+ * オブジェクトをコピーする
+ *
+ * 引数にオブジェクトが渡されたら、
+ * そのオブジェクトの持つプロパティでコンストラクタへの引数が上書きされる。
+ *
+ * Scalaのcase classに生えるcopyメソッドを参考にしている。
+ *
+ * @param {Object} obj
+ * @return {T}
+ */
+ copy(obj) {
+ const merged = {};
+ Object.assign(merged, this);
+ Object.assign(merged, obj || {});
+
+ const copied = Object.create(this.constructor.prototype);
+ Object.assign(copied, merged);
+
+ return copied;
+ }
+}
diff --git a/src/main.js b/src/main.js
new file mode 100644
index 0000000..236b64a
--- /dev/null
+++ b/src/main.js
@@ -0,0 +1,228 @@
+import { app } from 'hyperapp';
+import { ConfigStore } from './config_store.js';
+import { Config } from './config.js';
+import { Bookmark, BookmarkScope } from './domain/bookmark.js';
+import { Work } from './domain/work.js';
+import { Tags, Tag } from './domain/tag.js';
+import * as ui from './ui.js';
+
+export class AutoTagService {
+ /**
+ * @param {ConfigStore} configStore
+ */
+ constructor(configStore) {
+ this.configStore = configStore;
+ }
+
+ /**
+ * @param {Work} work
+ * @param {Tags} tagCloud
+ * @return {Bookmark?}
+ */
+ execute(tagCloud, work) {
+ const config = this.configStore.load() || Config.default();
+
+ const ruleResult = config.rule();
+ if (ruleResult.errors) {
+ alert(
+ ruleResult.errors
+ .map(({ message, lineNumber }) => `${lineNumber}: ${message}`)
+ .join('\n'),
+ );
+ return null;
+ }
+
+ const rule = ruleResult.success;
+
+ if (!rule) {
+ return null;
+ }
+
+ const commonTags = work.tags.intersect(tagCloud);
+
+ const bookmark = Bookmark.empty().withTags(commonTags);
+
+ return rule.process(work, bookmark);
+ }
+}
+
+/**
+ * 自動タグ付けを実行する
+ */
+function autoTag() {
+ const tagCloud = findTagCloud();
+ const work = findWork();
+
+ const bookmark = autoTagService.execute(tagCloud, work);
+ if (!bookmark) {
+ return;
+ }
+
+ const form = bookmarkForm();
+
+ setValueForReact(form.comment, 'value', bookmark.comment);
+ setValueForReact(form.tag, 'value', bookmark.tags.toArray().join(' '));
+ setValueForReact(
+ form.restrict,
+ 'value',
+ bookmark.scope === BookmarkScope.Public ? '0' : '1',
+ );
+
+ /** @param {NodeList} nodeList */
+ function tagsFromNodes(nodeList) {
+ const tags = Array.from(nodeList).map((tagNode) => {
+ const tagRaw = tagNode.textContent || '';
+ const tag = tagRaw.replace(/^\*/, '');
+
+ return Tag.for(tag);
+ });
+
+ return Tags.fromIterable(tags);
+ }
+
+ /** @return {Tags} */
+ function findTagCloud() {
+ const tagListNodes = document.querySelectorAll(
+ 'section.tag-cloud-container > ul.tag-cloud > li',
+ );
+
+ return tagsFromNodes(tagListNodes);
+ }
+
+ function findWork() {
+ const titleNode = document.querySelector('.bookmark-detail-unit h1.title');
+ const title = (titleNode && titleNode.textContent) || '';
+
+ const workTagNodes = document.querySelectorAll(
+ 'div.recommend-tag > ul span.tag',
+ );
+ const tags = tagsFromNodes(workTagNodes);
+
+ return new Work(title, tags);
+ }
+
+ /**
+ * @param {HTMLInputElement} input
+ * @param {string} key
+ * @param {string} value
+ */
+ function setValueForReact(input, key, value) {
+ const valueSetterDescriptor = Object.getOwnPropertyDescriptor(input, key);
+
+ if (!valueSetterDescriptor) {
+ input.setAttribute(key, value);
+ return;
+ }
+
+ const valueSetter = valueSetterDescriptor.set;
+ const proto = Object.getPrototypeOf(input);
+ const prototypeValueSetterOpt = Object.getOwnPropertyDescriptor(proto, key);
+ const prototypeValueSetter =
+ prototypeValueSetterOpt && prototypeValueSetterOpt.set;
+
+ if (
+ valueSetter &&
+ prototypeValueSetter &&
+ valueSetter !== prototypeValueSetter
+ ) {
+ prototypeValueSetter.call(input, value);
+ } else if (valueSetter) {
+ valueSetter.call(input, value);
+ } else {
+ throw new Error('入力欄に値を設定できませんでした。');
+ }
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ }
+}
+
+/**
+ * @return {{comment: HTMLInputElement, tag: HTMLInputElement, restrict: HTMLInputElement}}
+ */
+function bookmarkForm() {
+ const form = document.querySelector('.bookmark-detail-unit > form');
+
+ if (!form) {
+ throw new Error(
+ 'ブックマークの入力欄が見つけられませんでした。プログラムの修正が必要なため、作者に連絡してください。',
+ );
+ }
+
+ if (!('comment' in form && 'tag' in form && 'restrict' in form)) {
+ throw new Error(
+ 'ブックマークの入力欄に期待する欄がありませんでした。プログラムの修正が必要なため、作者に連絡してください。',
+ );
+ }
+
+ return form;
+}
+
+function findBookmark() {
+ const form = bookmarkForm();
+
+ return new Bookmark(
+ form.comment.value,
+ Tags.fromIterable(form.tag.value.split(/\s*/).map(Tag.for)),
+ form.restrict.value === '0' ? BookmarkScope.Public : BookmarkScope.Private,
+ );
+}
+
+function render() {
+ const container = document.createElement('span');
+ app(
+ ui.state(configStore),
+ ui.actions(autoTag, configStore),
+ ui.view,
+ container,
+ );
+
+ const prevElem = document.querySelector('.recommend-tag > h1.title');
+ if (prevElem && prevElem.parentNode) {
+ prevElem.parentNode.insertBefore(container, prevElem.nextSibling);
+ } else {
+ window.alert(
+ 'UIの描画に失敗しました。pixivのUIが変更されていると思われます。プログラムの修正が必要なため、作者に連絡してください。',
+ );
+ }
+}
+
+function execute() {
+ render();
+ if (!findBookmark().isEmpty()) return;
+ autoTag();
+}
+
+function onLoad() {
+ /** @param {string} url */
+ const isIllustPage = (url) => /member_illust.php/.test(url);
+ /** @param {string} url */
+ const isBookmarkPage = (url) => /bookmark_add/.test(url);
+
+ const observer = new MutationObserver((records) => {
+ const isReady = records.some(
+ (record) => record.type === 'childList' && record.addedNodes.length > 0,
+ );
+
+ if (isReady) setTimeout(execute, 999);
+ });
+
+ let target;
+ if (isIllustPage(location.href)) {
+ target = document.querySelector(
+ 'section.list-container.tag-container.work-tags-container > div > ul',
+ );
+ } else if (isBookmarkPage(location.href)) {
+ target = document.querySelector(
+ 'ul.list-items.tag-cloud.loading-indicator',
+ );
+ }
+
+ if (target) {
+ observer.observe(target, { childList: true });
+ } else {
+ // window.alert('タグリストを見つけられませんでした。修正が必要なため、作者に連絡してください。');
+ }
+}
+
+const configStore = new ConfigStore();
+const autoTagService = new AutoTagService(configStore);
+onLoad();
diff --git a/src/ui.js b/src/ui.js
new file mode 100644
index 0000000..95a315a
--- /dev/null
+++ b/src/ui.js
@@ -0,0 +1,179 @@
+import { h } from 'hyperapp';
+import hyperx from 'hyperx';
+import { Config } from './config.js';
+import views from './view.js';
+
+const hx = hyperx(h);
+
+/**
+ * @typedef {import('./config_store.js').ConfigStore} ConfigStore
+ */
+
+/**
+ * 設定に関する状態遷移
+ */
+class ConfigState {
+ /**
+ * @param {object} override
+ */
+ constructor(override) {
+ Object.assign(this, override);
+ }
+
+ toggle() {
+ return this;
+ }
+
+ change() {
+ return this;
+ }
+
+ save() {
+ return this;
+ }
+
+ reset() {
+ return this;
+ }
+
+ discard() {
+ return this;
+ }
+
+ keep() {
+ return this;
+ }
+}
+
+ConfigState.Closed = new ConfigState({
+ toggle() {
+ return ConfigState.NoChange;
+ },
+});
+
+ConfigState.NoChange = new ConfigState({
+ toggle() {
+ return ConfigState.Closed;
+ },
+ change() {
+ return ConfigState.Changed;
+ },
+});
+
+ConfigState.Changed = new ConfigState({
+ toggle() {
+ return ConfigState.AskClose;
+ },
+ save() {
+ return ConfigState.NoChange;
+ },
+ reset() {
+ return ConfigState.NoChange;
+ },
+});
+
+ConfigState.AskClose = new ConfigState({
+ discard() {
+ return ConfigState.NoChange;
+ },
+ keep() {
+ return ConfigState.Changed;
+ },
+});
+
+/** @typedef {{ configState: ConfigState, ruleRaw: string }} AppState */
+
+/** @type {(configRepository: ConfigStore) => AppState} */
+export const state = (configRepository) => ({
+ configState: ConfigState.Closed,
+ ruleRaw: (configRepository.load() || Config.default()).ruleRaw,
+});
+
+/**
+ * @param {() => void} autoTag
+ * @param {ConfigStore} configRepository
+ */
+export const actions = (autoTag, configRepository) => {
+ const self = {
+ /** @type {() => (state: AppState) => AppState} */
+ executeAutoTag: () => (state) => {
+ autoTag();
+ return state;
+ },
+
+ /** @type {() => (state: AppState) => AppState} */
+ configToggle: () => (state) => {
+ const newState = { ...state, configState: state.configState.toggle() };
+
+ if (newState.configState === ConfigState.AskClose) {
+ const message = '設定が変更されています。破棄してもよろしいですか?';
+ const result = window.confirm(message);
+
+ if (result) {
+ return self.configDiscardChange()(newState);
+ }
+ return self.configKeepChange()(newState);
+ }
+
+ return newState;
+ },
+
+ /**
+ * @typedef {{ ruleRaw: string }} ConfigSaveIn
+ * @type {(arg0: ConfigSaveIn) => (state: AppState) => AppState}
+ */
+ configSave: ({ ruleRaw }) => (state) => {
+ const config = Config.create(ruleRaw);
+
+ try {
+ configRepository.save(config);
+ alert('保存しました');
+ } catch (e) {
+ alert(`保存に失敗しました: ${e}`);
+ }
+
+ return { ...state, ruleRaw, configState: state.configState.save() };
+ },
+
+ /**
+ * @type {() => (state: AppState) => AppState}
+ */
+ configUpdate: () => (state) => ({
+ ...state,
+ configState: state.configState.change(),
+ }),
+
+ /** @type {() => (state: AppState) => AppState} */
+ configDiscardChange: () => (state) => ({
+ ...state,
+ configState: state.configState.discard(),
+ }),
+
+ /** @type {() => (state: AppState) => AppState} */
+ configKeepChange: () => (state) => ({
+ ...state,
+ configState: state.configState.keep(),
+ }),
+
+ /** @type {() => (state: AppState) => AppState} */
+ configDownload: () => (state) => state,
+ };
+
+ return self;
+};
+
+/**
+ * @param {AppState} state
+ * @param {typeof actions} actions
+ */
+export const view = (state, actions) => {
+ const open = state.configState !== ConfigState.Closed;
+ const h = hx`
+
+ ${views.buttons(actions, state)}
+ ${open ? views.config(actions, state) : ''}
+
+ `;
+
+ return h;
+};
diff --git a/src/view.js b/src/view.js
new file mode 100644
index 0000000..efbf714
--- /dev/null
+++ b/src/view.js
@@ -0,0 +1,89 @@
+import { h } from 'hyperapp';
+import hyperx from 'hyperx';
+
+/**
+ * @typedef {import('./ui.js').AppState} AppState
+ */
+
+const hx = hyperx(h);
+
+/**
+ * ボタンを描画する
+ *
+ * @param {Actions} actions
+ */
+export function buttons(actions) {
+ const configId = 'autotagConfigToggle';
+ const autotagId = 'autotagExec';
+
+ const v = hx`
+
+
+
+
+ `;
+
+ return v;
+}
+
+/**
+ * 設定画面を描画する
+ *
+ * @param {Actions} actions
+ * @param {{ruleRaw: string}} arg1
+ */
+export function config(actions, { ruleRaw }) {
+ const formId = 'autotagConfigForm';
+ const ruleId = 'autotagConfigForm__Rule';
+ const saveId = 'autotagConfigForm__Save';
+
+ /** @type {(event: InputEvent) => void} */
+ const onsubmit = (event) => {
+ event.preventDefault();
+ event.target &&
+ actions.configSave({
+ ruleRaw: event.target.querySelector(`#${ruleId}`).value,
+ });
+ };
+
+ /** @type {(event: InputEvent) => void} */
+ const download = (event) => {
+ event.preventDefault();
+ actions.configDownload();
+ };
+
+ const v = hx`
+
+ `;
+
+ return v;
+}
+
+const view = {
+ buttons,
+ config,
+};
+
+export default view;
diff --git a/test/autotag.test.js b/test/autotag.test.js
new file mode 100644
index 0000000..e72518d
--- /dev/null
+++ b/test/autotag.test.js
@@ -0,0 +1,53 @@
+import assert from 'assert';
+import { Bookmark, BookmarkScope } from '../src/domain/bookmark.js';
+import { Tag, Tags } from '../src/domain/tag.js';
+import { Work } from '../src/domain/work.js';
+import { Rule, Pattern } from '../src/domain/rule.js';
+
+describe('ConfigRuleParser', () => {
+ const work = new Work(
+ 'イラスト',
+ Tags.fromIterable([
+ Tag.for('艦これ'),
+ Tag.for('響(艦隊これくしょん)'),
+ Tag.for('艦これかわいい'),
+ ]),
+ );
+
+ const bookmark = new Bookmark('comment', Tags.empty(), BookmarkScope.Public);
+
+ const tagCloud = new Tags([
+ Tag.for('艦隊これくしょん'),
+ Tag.for('響'),
+ Tag.for('艦これかわいい'),
+ Tag.for('艦これ'),
+ ]);
+
+ ///////////
+
+ const rules = [
+ Rule.removeAll(Tag.for('艦これ'), [Pattern.regexp('^艦これ$')]),
+ Rule.appendAll(Tag.for('艦隊これくしょん'), [Pattern.regexp('^艦これ$')]),
+ Rule.appendSome(Tag.for('響'), [
+ Pattern.regexp('ベールヌイ'),
+ Pattern.regexp('響'),
+ ]),
+ Rule.privateSome([Pattern.regexp('響')]),
+ ];
+
+ // TBD addition Ruleの実行
+
+ // 共通タグの抽出
+ const commonTags = work.tags.intersect(tagCloud);
+ const bookmarkWithCommonTags = bookmark.withTags(commonTags);
+
+ // 付与タグリストの生成
+ const taggedBookmark = rules.reduce(
+ (bookmark, rule) => rule(work)(bookmark),
+ bookmarkWithCommonTags,
+ );
+ assert(taggedBookmark.tags.has(Tag.for('艦隊これくしょん')));
+ assert(taggedBookmark.tags.has(Tag.for('響')));
+ assert(taggedBookmark.tags.has(Tag.for('艦これかわいい')));
+ assert(taggedBookmark.tags.size() === 3);
+});
diff --git a/test/config_rule_parser.test.js b/test/config_rule_parser.test.js
new file mode 100644
index 0000000..4b64b1b
--- /dev/null
+++ b/test/config_rule_parser.test.js
@@ -0,0 +1,45 @@
+import assert from 'assert';
+import { ConfigRuleParser } from '../src/config_rule_parser.js';
+
+// import util from 'util';
+
+describe('ConfigRuleParser', () => {
+ const parser = new ConfigRuleParser();
+
+ describe('#parse', () => {
+ describe('when collect rule is given', () => {
+ it('should parse rule', () => {
+ const rule =
+ '# 非公開設定\n' +
+ 'private R-18 R-18G R-17.9 R-15\n' +
+ '\n' +
+ '# 一般\n' +
+ 'pattern オリジナル オリジナル\n' +
+ '# 艦これ\n' +
+ 'match -~1 艦これ\n' +
+ 'match_all アリス・キャロル ARIA アリス\n' +
+ 'match_all -アリス ARIA アリス\n' +
+ 'pattern_all ~1 ^艦これ$|^艦隊これくしょん$ ^(.+)(改|改二)$\n' +
+ 'pattern ~1 ^(.+)(艦隊これくしょん)$\n' +
+ 'match 卯月 うーちゃん\n' +
+ '# 東方\n' +
+ 'match_all 多々良小傘 東方 小傘\n';
+ // 'addition_pattern_all ~1(アズールレーン) ^アズールレーン|アズレン|碧蓝航线$ ^睦月|如月|卯月|水無月)$';
+
+ const result = parser.parse(rule);
+ assert(result.success);
+ // console.log(util.inspect(result.success.rules, false, null));
+ });
+ });
+
+ describe('when invalid rule is given', () => {
+ it('should ends up with error', () => {
+ const rule = 'macth_all 多々良小傘 東方 小傘\n';
+
+ const result = parser.parse(rule);
+ assert(!result.success);
+ // console.log(util.inspect(result.success.rules, false, null));
+ });
+ });
+ });
+});
diff --git a/test/pattern.test.js b/test/pattern.test.js
new file mode 100644
index 0000000..ddba3ba
--- /dev/null
+++ b/test/pattern.test.js
@@ -0,0 +1,47 @@
+import assert from 'assert';
+import { Pattern, Match } from '../src/domain/rule.js';
+import { Tag } from '../src/domain/tag.js';
+
+describe('Pattern', () => {
+ describe('.exact', () => {
+ it('should return a new Pattern', () => {
+ const pattern = Pattern.exact('あああ');
+
+ assert(pattern instanceof Pattern);
+ });
+ });
+
+ describe('.regexp', () => {
+ it('should return a new Pattern', () => {
+ const pattern = Pattern.regexp('[0-9]*');
+
+ assert(pattern instanceof Pattern);
+ });
+ });
+
+ describe('#match', () => {
+ describe('when matched', () => {
+ it('should return Match and Match#succeeded should return true', () => {
+ const pattern = Pattern.regexp('^[あかさ]+$');
+ const tag = Tag.for('あかさかさかあか');
+
+ const match = pattern.match(tag);
+
+ assert(match instanceof Match);
+ assert(match.succeeded());
+ });
+ });
+
+ describe('when did not matched', () => {
+ it('should return Match and Match#failed should return true', () => {
+ const pattern = Pattern.exact('あいうえお');
+ const tag = Tag.for('かきくけこ');
+
+ const match = pattern.match(tag);
+
+ assert(match instanceof Match);
+ assert(match.failed());
+ });
+ });
+ });
+});
diff --git a/test/rule.test.js b/test/rule.test.js
new file mode 100644
index 0000000..80e7cc7
--- /dev/null
+++ b/test/rule.test.js
@@ -0,0 +1,150 @@
+import assert from 'assert';
+import { Tag, Tags } from '../src/domain/tag.js';
+import { Bookmark } from '../src/domain/bookmark.js';
+import { Work } from '../src/domain/work.js';
+import { Pattern, Rule } from '../src/domain/rule.js';
+
+describe('Rule', () => {
+ const tagStr = 'タグ';
+ const tag = Tag.for(tagStr);
+
+ const tagStr1 = 'タグ1';
+ const tag1 = Tag.for(tagStr1);
+
+ const work = new Work('作品名', Tags.fromArgs(tag, tag1));
+
+ describe('.appendSome', () => {
+ const ruleMatch = Rule.appendSome(Tag.for('追加~0'), [
+ Pattern.exact('マッチしない1'),
+ Pattern.exact('マッチしない2'),
+ Pattern.exact('マッチしない3'),
+ Pattern.regexp('^タグ1?$'),
+ ]);
+
+ const ruleNotMatch = Rule.appendSome(Tag.for('追加タグ'), [
+ Pattern.exact('マッチしない'),
+ ]);
+
+ describe('#process', () => {
+ describe('when rule matches a tag', () => {
+ it('should return bookmark which has a tag', () => {
+ const appendedTag = Tag.for('追加タグ');
+ const appendedTag1 = Tag.for('追加タグ1');
+ const resultBookmark = ruleMatch(work)(Bookmark.empty());
+ assert(resultBookmark.tags.has(appendedTag));
+ assert(resultBookmark.tags.has(appendedTag1));
+ });
+ });
+
+ describe('when rule does not match a tag', () => {
+ it('should return bookmark which does not have a tag', () => {
+ const tag = Tag.for('追加タグ');
+ const resultBookmark = ruleNotMatch(work)(Bookmark.empty());
+ assert(!resultBookmark.tags.has(tag));
+ });
+ });
+ });
+ });
+
+ describe('.appendAll', () => {
+ const ruleMatch = Rule.appendAll(Tag.for('追加タグ'), [
+ Pattern.exact('タグ1'),
+ Pattern.exact('タグ'),
+ ]);
+
+ const ruleNotMatch = Rule.appendAll(Tag.for('追加タグ'), [
+ Pattern.exact('タグ'),
+ Pattern.exact('タグ1'),
+ Pattern.exact('マッチしない'),
+ ]);
+
+ describe('#process', () => {
+ describe('when rule matches a tag', () => {
+ it('should return bookmark which has a tag', () => {
+ const tag = Tag.for('追加タグ');
+ const resultBookmark = ruleMatch(work)(Bookmark.empty());
+ assert(resultBookmark.tags.has(tag));
+ });
+ });
+
+ describe('when rule does not match a tag', () => {
+ it('should return bookmark which does not have a tag', () => {
+ const tag = Tag.for('追加タグ');
+ const resultBookmark = ruleNotMatch(work)(Bookmark.empty());
+ assert(!resultBookmark.tags.has(tag));
+ });
+ });
+ });
+ });
+
+ describe('.removeSome', () => {
+ const tagRef = Tag.for('タグ');
+
+ const ruleMatch = Rule.removeSome(tagRef, [
+ Pattern.exact('マッチしない1'),
+ Pattern.exact('マッチしない2'),
+ Pattern.exact('マッチしない3'),
+ Pattern.exact('タグ'),
+ ]);
+
+ const ruleNotMatch = Rule.removeSome(tagRef, [
+ Pattern.exact('マッチしない'),
+ Pattern.exact('マッチしない1'),
+ Pattern.exact('マッチしない2'),
+ Pattern.exact('マッチしない3'),
+ ]);
+
+ const commonTags = Tags.fromArgs(tag);
+ const bookmark = Bookmark.empty().withTags(commonTags);
+
+ describe('#process', () => {
+ describe('when rule matches a tag', () => {
+ it('should return bookmark which does not have a tag', () => {
+ const resultBookmark = ruleMatch(work)(bookmark);
+ assert(!resultBookmark.tags.has(tag));
+ });
+ });
+
+ describe('when rule does not match a tag', () => {
+ it('should return bookmark which has a tag', () => {
+ const resultBookmark = ruleNotMatch(work)(bookmark);
+ assert(resultBookmark.tags.has(tag));
+ });
+ });
+ });
+ });
+
+ describe('.removeAll', () => {
+ const tagRef = Tag.for('タグ');
+
+ const ruleMatch = Rule.removeAll(tagRef, [
+ Pattern.exact('タグ'),
+ Pattern.exact('タグ1'),
+ ]);
+
+ const ruleNotMatch = Rule.removeAll(tagRef, [
+ Pattern.exact('タグ'),
+ Pattern.exact('タグ1'),
+ Pattern.exact('マッチしない'),
+ ]);
+
+ const commonTags = Tags.fromArgs(tag);
+ const bookmark = Bookmark.empty().withTags(commonTags);
+
+ describe('#process', () => {
+ describe('when rule matches a tag', () => {
+ it('should return bookmark which does not have a tag', () => {
+ const resultBookmark = ruleMatch(work)(bookmark);
+ assert(!resultBookmark.tags.has(tag));
+ });
+ });
+
+ describe('when rule does not match a tag', () => {
+ it('should return bookmark which has a tag', () => {
+ const resultBookmark = ruleNotMatch(work)(bookmark);
+ assert(resultBookmark.tags.has(tag));
+ });
+ });
+ });
+ });
+});
diff --git a/test/tags.test.js b/test/tags.test.js
new file mode 100644
index 0000000..2ac4a33
--- /dev/null
+++ b/test/tags.test.js
@@ -0,0 +1,120 @@
+import assert from 'assert';
+import { Tag, Tags } from '../src/domain/tag.js';
+
+const tagsArrayA = [
+ '艦隊これくしょん',
+ '艦これ',
+ '卯月',
+ '卯月(艦隊これくしょん)',
+ '艦隊これくしょん',
+].map((txt) => Tag.for(txt));
+
+const tagsArrayB = ['艦隊これくしょん', '卯月', '艦これかわいい'].map((txt) =>
+ Tag.for(txt),
+);
+
+const tagsArrayDiff = ['艦これ', '卯月(艦隊これくしょん)'].map((txt) =>
+ Tag.for(txt),
+);
+
+const tagsArrayCommon = ['艦隊これくしょん', '卯月'].map((txt) => Tag.for(txt));
+
+const tagsArrayUnion = tagsArrayA.concat(tagsArrayB);
+
+const tagsA = new Tags(tagsArrayA);
+const tagA = tagsA.toArray()[0];
+const tagsAA = new Tags(tagsArrayA);
+const tagsB = new Tags(tagsArrayB);
+const tagsDiff = new Tags(tagsArrayDiff);
+const tagsCommon = new Tags(tagsArrayCommon);
+const tagsUnion = new Tags(tagsArrayUnion);
+const tagsX = new Tags([]);
+const tagsZ = new Tags([]);
+
+describe('Tags', () => {
+ describe('#toArray', () => {
+ it('should return array of Tag', () => {
+ assert(
+ tagsA.toArray().every((tag, index) => tagsArrayA[index].equals(tag)),
+ );
+ assert.equal(tagsA.toArray().length, 4);
+
+ assert.equal(tagsZ.toArray(), 0);
+ });
+ });
+
+ describe('#has', () => {
+ describe('when it contains a given tag', () => {
+ it('should return true', () => {
+ assert(tagsArrayA.every((tag) => tagsA.has(tag)));
+ assert(tagsArrayB.every((tag) => tagsB.has(tag)));
+ });
+ });
+
+ describe('when it does not contain a given tag', () => {
+ it('should return false', () => {
+ assert(!tagsA.has(Tag.for('睦月')));
+ assert(!tagsB.has(Tag.for('如月')));
+ assert(!tagsZ.has(Tag.for('弥生')));
+ });
+ });
+ });
+
+ describe('#equals', () => {
+ describe('when argument has same contents', () => {
+ it('should return true', () => {
+ assert(tagsA.equals(tagsAA));
+ });
+ });
+
+ describe('when argument does not have same contents', () => {
+ it('should return false', () => {
+ assert(!tagsA.equals(tagsB));
+ });
+ });
+ });
+
+ describe('#union', () => {
+ it('should return union', () => {
+ assert(tagsA.union(tagsB).equals(tagsUnion));
+ });
+ });
+
+ describe('#diff', () => {
+ it('should return difference', () => {
+ assert(tagsA.diff(tagsB).equals(tagsDiff));
+ assert(tagsA.diff(tagsZ).equals(tagsA));
+ });
+ });
+
+ describe('#intersect', () => {
+ it('should return intersect', () => {
+ assert(tagsA.intersect(tagsB).equals(tagsCommon));
+ assert(tagsA.intersect(tagsZ).equals(tagsZ));
+ });
+ });
+
+ describe('#append', () => {
+ it('should return a new Tags which has a given tag', () => {
+ const tag = Tag.for('あ');
+ assert(!tagsA.has(tag));
+ assert(tagsA.append(tag).has(tag));
+ });
+ });
+
+ describe('#$append', () => {
+ it('should append a given tag to itself', () => {
+ const tag = Tag.for('あ');
+ assert(!tagsX.has(tag));
+ tagsX.$append(tag);
+ assert(tagsX.has(tag));
+ });
+ });
+
+ describe('#remove', () => {
+ it('should return a new Tags which does not have a given tag', () => {
+ assert(tagsA.has(tagA));
+ assert(!tagsA.remove(tagA).has(tagA));
+ });
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..f0f5a6b
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,73 @@
+{
+ "include": [
+ "src/**/*",
+ "test/**/*"
+ ],
+ "exclude": [
+ "node_modules",
+ "build"
+ ],
+ "compilerOptions": {
+ /* Basic Options */
+ "incremental": true, /* Enable incremental compilation */
+ "target": "ES2020", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
+ "module": "ESNext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
+ "lib": ["dom", "dom.iterable", "esnext"], /* Specify library files to be included in the compilation. */
+ "allowJs": true, /* Allow javascript files to be compiled. */
+ "checkJs": true, /* Report errors in .js files. */
+ // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
+ // "declaration": true, /* Generates corresponding '.d.ts' file. */
+ // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
+ // "sourceMap": true, /* Generates corresponding '.map' file. */
+ // "outFile": "./", /* Concatenate and emit output to single file. */
+ // "outDir": "./", /* Redirect output structure to the directory. */
+ // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
+ // "composite": true, /* Enable project compilation */
+ // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
+ // "removeComments": true, /* Do not emit comments to output. */
+ "noEmit": true, /* Do not emit outputs. */
+ // "importHelpers": true, /* Import emit helpers from 'tslib'. */
+ // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
+ // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
+
+ /* Strict Type-Checking Options */
+ "strict": true, /* Enable all strict type-checking options. */
+ // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
+ // "strictNullChecks": true, /* Enable strict null checks. */
+ // "strictFunctionTypes": true, /* Enable strict checking of function types. */
+ // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
+ // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
+ // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
+ // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
+
+ /* Additional Checks */
+ // "noUnusedLocals": true, /* Report errors on unused locals. */
+ // "noUnusedParameters": true, /* Report errors on unused parameters. */
+ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
+ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
+
+ /* Module Resolution Options */
+ "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
+ // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
+ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
+ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
+ // "typeRoots": [], /* List of folders to include type definitions from. */
+ // "types": [], /* Type declaration files to be included in compilation. */
+ // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
+ "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
+ // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
+ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
+
+ /* Source Map Options */
+ // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
+ // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
+ // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
+ // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
+
+ /* Experimental Options */
+ // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
+ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
+ }
+}
+
+// vim: set ft=javascript et sw=2 ts=2 :