diff --git a/CLAUDE.md b/CLAUDE.md index 43099c2..802d45e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,15 @@ # Claude Code Configuration +## Project Setup + +**IMPORTANT**: Before starting any work on a repository: +1. Check for a `CLAUDE.md` file in the repository root +2. Check for a `CLAUDE.md` file in parent directories (we often work across repos on localhost) +3. Read and follow all instructions in those files +4. These project-specific instructions override default Claude Code behavior +5. Look for conventions around commits, testing, code style, and workflows +6. If working across multiple repositories, respect the conventions from each repo's CLAUDE.md + ## Commit Attribution When creating git commits, use clean, simple commit messages: diff --git a/remarkbox/models/namespace.py b/remarkbox/models/namespace.py index a19107f..976f343 100644 --- a/remarkbox/models/namespace.py +++ b/remarkbox/models/namespace.py @@ -107,6 +107,9 @@ class Namespace(RBase, Base): reverse_order = Column(Boolean, default=False) # should we group conversations and limit to nesting 2 deep? group_conversations = Column(Boolean, default=False) + # the group postfix used for imports (e.g., "rb" creates "Anonymous-rb") + # Once set, this becomes permanent for all imported surrogates + import_group_postfix = Column(Unicode(6), default=None, nullable=True) # the type of subscription of this Namespace. subscription_type = Column( Enum(*SUBSCRIPTION_TYPES, name="subscription_type"), diff --git a/remarkbox/routes.py b/remarkbox/routes.py index 7fd724b..08a266a 100644 --- a/remarkbox/routes.py +++ b/remarkbox/routes.py @@ -71,6 +71,7 @@ def includeme(config): config.add_route("embed-namespace-nodes", "/embed/ns/{namespace}/nodes") config.add_route("embed-namespace-settings", "/embed/ns/{namespace}/settings") + config.add_route("embed-namespace-import-comments", "/embed/ns/{namespace}/import-comments") config.add_route( "embed-namespace-stylesheet", "/embed/ns/{namespace}/{filename}.css" ) @@ -102,6 +103,7 @@ def includeme(config): config.add_route("basic-namespace-nodes", "/ns/{namespace}/nodes") config.add_route("basic-namespace-settings", "/ns/{namespace}/settings") + config.add_route("basic-namespace-import-comments", "/ns/{namespace}/import-comments") config.add_route("basic-namespace-stats-json", "/ns/{namespace}/stats.json") config.add_route("basic-namespace-stylesheet", "/ns/{namespace}/{filename}.css") config.add_route("basic-namespace-threads-rss", "/ns/{namespace}.threads.xml") diff --git a/remarkbox/scripts/alembic/versions/5188e62d0afb_add_import_group_postfix_column_to_.py b/remarkbox/scripts/alembic/versions/5188e62d0afb_add_import_group_postfix_column_to_.py new file mode 100644 index 0000000..d41d20b --- /dev/null +++ b/remarkbox/scripts/alembic/versions/5188e62d0afb_add_import_group_postfix_column_to_.py @@ -0,0 +1,33 @@ +"""Add import_group_postfix column to namespace table + +Revision ID: 5188e62d0afb +Revises: b8f3c9d4e5a1 +Create Date: 2025-11-24 06:39:06.299392 + +""" + +# revision identifiers, used by Alembic. +revision = '5188e62d0afb' +down_revision = 'b8f3c9d4e5a1' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + + +from sqlalchemy_utils import UUIDType as TempUUIDType +UUIDType = TempUUIDType(binary=False) + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('rb_namespace', sa.Column('import_group_postfix', sa.Unicode(length=6), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('rb_namespace', 'import_group_postfix') + # ### end Alembic commands ### diff --git a/remarkbox/templates/import-comments.j2 b/remarkbox/templates/import-comments.j2 new file mode 100644 index 0000000..e308da0 --- /dev/null +++ b/remarkbox/templates/import-comments.j2 @@ -0,0 +1,83 @@ +{% extends request.base_template -%} + +{% block title %}{{ the_title }} | {{ request.domain }}{%- endblock -%} +{% block content -%} + +

{{ the_title }}

+ +

+Import comments and threads from WordPress, Disqus, or pre-formatted JSON exports using the +blog-to-json tool. +

+ +
+

How to Use

+ +
    +
  1. Export your comments: +
      +
    • Install the blog-to-json tool: pip install blog-to-json
    • +
    • Use blog-to-json to convert your export file to JSON format
    • +
    • See the tool documentation for supported platforms and usage
    • +
    +
  2. +
  3. Automatic user creation: +
      +
    • Comments with email addresses will create or match existing users
    • +
    • Comments without email addresses will create surrogate users (guest users specific to your namespace)
    • +
    • All imported users will be tagged with a unique timestamp to identify them as imported from this session
    • +
    +
  4. +
  5. Upload and import: Select your JSON file and click "Import Comments".
  6. +
+ +

Note: The import process will: +

+

+ +

Get the tool: https://github.com/russellballestrini/blog-to-json

+
+ +
+ +
+ + + +
+Select the JSON file generated by blog-to-json +
+
+ + + +
+{% if postfix_locked %} +Locked: This namespace is using the permanent postfix "{{ default_prefix }}". All imported users will be tagged with this postfix (e.g., "Anonymous-{{ default_prefix }}"). Re-uploading will reuse existing users with this postfix. +{% else %} +Short postfix (2-6 alphanumeric chars) to tag imported users. Auto-generated from your namespace domain, but you can customize it. Once used, this postfix becomes permanent. Re-uploading with the same postfix will reuse existing users. +{% endif %} +
+
+ +{% include 'snippets/csrf.j2' %} + +
+ +{% set submit_button_classes = 'button-right green-button' %} +{% set submit_button_value = 'Import Comments' %} +{% include 'snippets/submit.j2' %} + +
+ +
+
+ +Back to Namespace Settings + +{%- endblock -%} diff --git a/remarkbox/templates/namespace-settings.j2 b/remarkbox/templates/namespace-settings.j2 index 1f4420e..5645cf0 100644 --- a/remarkbox/templates/namespace-settings.j2 +++ b/remarkbox/templates/namespace-settings.j2 @@ -275,4 +275,18 @@ These people may modify Namespace settings (this page). +
+ +
+ +
+ +

Import Comments

+ + +Import comments and threads using the blog-to-json tool. +
+
+Import Comments + {%- endblock -%} diff --git a/remarkbox/tests/test_import_comments.py b/remarkbox/tests/test_import_comments.py new file mode 100644 index 0000000..24af5f7 --- /dev/null +++ b/remarkbox/tests/test_import_comments.py @@ -0,0 +1,834 @@ +import transaction +import unittest +import webtest +import json +import io + +from remarkbox.models import ( + Node, + get_tm_session, + get_or_create_user_by_email, + get_user_by_email, + get_or_create_namespace, +) + +from remarkbox.models.meta import Base +from pyramid.paster import get_appsettings + +try: + unicode("") +except: + from six import u as unicode + + +class ImportCommentsFunctionalTests(unittest.TestCase): + """Tests for the import comments functionality""" + + @classmethod + def setUpClass(cls): + from remarkbox import main + + cls.settings = get_appsettings("test.ini") + cls.app = main({}, **cls.settings) + cls.testapp = webtest.TestApp(cls.app) + + cls.session_factory = cls.app.registry["dbsession_factory"] + cls.engine = cls.session_factory.kw["bind"] + Base.metadata.create_all(bind=cls.engine) + + cls.tm = transaction.manager + cls.dbsession = get_tm_session(cls.session_factory, cls.tm) + + @classmethod + def tearDownClass(cls): + cls.dbsession.close() + Base.metadata.drop_all(bind=cls.engine) + + def setUp(self): + # Create test user and authenticate + self.test_user = get_or_create_user_by_email( + self.dbsession, "owner@example.com" + ) + self.raw_otp = self.test_user.new_password() + self.dbsession.add(self.test_user) + self.dbsession.flush() + self.tm.commit() + + # Requery to avoid detached instance error + self.test_user = get_or_create_user_by_email( + self.dbsession, "owner@example.com" + ) + + # Log in the test user + self.testapp.post( + f"/verification-challenge?email=owner@example.com&raw-otp={self.raw_otp}&submit" + ) + + # Get CSRF token + res_csrf = self.testapp.get("/") + self.csrf = res_csrf.form.fields["csrf_token"][0].value + + # Create a test namespace and make the user an owner + self.test_namespace = get_or_create_namespace( + self.dbsession, "test.example.com" + ) + self.test_namespace.set_role_for_user(self.test_user, role="owner") + self.dbsession.add(self.test_namespace) + self.dbsession.flush() + self.tm.commit() + + def tearDown(self): + # Clean up: log out + self.testapp.get("/log-out") + + def get_test_namespace(self): + """Helper to get fresh namespace object from DB""" + from remarkbox.models import get_namespace_by_name + return get_namespace_by_name(self.dbsession, "test.example.com") + + def test_import_page_requires_authentication(self): + """Test that the import page requires user authentication""" + self.testapp.get("/log-out") + redirect_res = self.testapp.get( + "/ns/test.example.com/import-comments", status=302 + ) + res = redirect_res.follow() + self.assertIn(b"You must log in to access that area.", res.body) + + def test_import_page_requires_namespace_ownership(self): + """Test that the import page requires namespace ownership""" + # Create a different user + other_user = get_or_create_user_by_email( + self.dbsession, "other@example.com" + ) + other_otp = other_user.new_password() + self.dbsession.add(other_user) + self.dbsession.flush() + self.tm.commit() + + # Log out current user and log in as other user + self.testapp.get("/log-out") + self.testapp.post( + f"/verification-challenge?email=other@example.com&raw-otp={other_otp}&submit" + ) + + # Try to access import page + redirect_res = self.testapp.get( + "/ns/test.example.com/import-comments", status=302 + ) + res = redirect_res.follow() + self.assertIn(b"You do not own that Namespace.", res.body) + + # Clean up + self.dbsession.delete(other_user) + self.dbsession.flush() + self.tm.commit() + + def test_import_page_displays_correctly(self): + """Test that the import page displays correctly for namespace owners""" + res = self.testapp.get("/ns/test.example.com/import-comments", status=200) + self.assertIn(b"Import Comments", res.body) + self.assertIn(b"blog-to-json", res.body) + self.assertIn(b"How to Use", res.body) + self.assertIn(b"JSON File:", res.body) + self.assertIn(b"Automatic user creation", res.body) + + def test_import_requires_file(self): + """Test that import fails without a file upload""" + res = self.testapp.post( + "/ns/test.example.com/import-comments", + { + "csrf_token": self.csrf, + }, + status=200, + ) + self.assertIn(b"Please select a JSON file to upload.", res.body) + + def test_import_invalid_json(self): + """Test that import fails with invalid JSON""" + invalid_json_content = b"{ invalid json content" + + res = self.testapp.post( + "/ns/test.example.com/import-comments", + { + "csrf_token": self.csrf, + }, + upload_files=[("json-file", "test.json", invalid_json_content)], + status=200, + ) + self.assertIn(b"Invalid JSON file", res.body) + + def test_import_valid_json_with_comments(self): + """Test successful import of comments from valid JSON""" + # Create a valid Disqus export JSON + disqus_data = { + "test-post": { + "link": "https://example.com/test-post", + "comments": [ + { + "id": "1", + "author": "John Doe", + "email": "john@example.com", + "content": "This is a test comment", + "timestamp": 1234567890, + "parent_id": None, + "author_ip": "127.0.0.1", + }, + { + "id": "2", + "author": "Jane Smith", + "email": "jane@example.com", + "content": "This is a reply", + "timestamp": 1234567900, + "parent_id": "1", + "author_ip": "127.0.0.2", + }, + ], + } + } + + json_content = json.dumps(disqus_data).encode("utf-8") + + res = self.testapp.post( + "/ns/test.example.com/import-comments", + { + "csrf_token": self.csrf, + }, + upload_files=[("json-file", "disqus.json", json_content)], + status=200, + ) + + self.assertIn(b"Successfully imported", res.body) + self.assertIn(b"1 threads", res.body) + self.assertIn(b"2 comments", res.body) + + def test_import_with_user_surrogates(self): + """Test import automatically creates surrogates for comments without email""" + disqus_data = { + "test-post": { + "link": "https://example.com/surrogate-test", + "comments": [ + { + "id": "1", + "author": "Anonymous User", + "email": "", + "content": "Anonymous comment", + "timestamp": 1234567890, + "parent_id": None, + "author_ip": "127.0.0.1", + }, + ], + } + } + + json_content = json.dumps(disqus_data).encode("utf-8") + + res = self.testapp.post( + "/ns/test.example.com/import-comments", + { + "csrf_token": self.csrf, + }, + upload_files=[("json-file", "disqus.json", json_content)], + status=200, + ) + + self.assertIn(b"Successfully imported", res.body) + self.assertIn(b"1 comments", res.body) + + def test_import_with_automatic_group_postfix(self): + """Test import automatically adds group postfix to all new users""" + disqus_data = { + "test-post": { + "link": "https://example.com/group-test", + "comments": [ + { + "id": "1", + "author": "Test User", + "email": "testgroup@example.com", + "content": "Test comment", + "timestamp": 1234567890, + "parent_id": None, + "author_ip": "127.0.0.1", + }, + ], + } + } + + json_content = json.dumps(disqus_data).encode("utf-8") + + res = self.testapp.post( + "/ns/test.example.com/import-comments", + { + "csrf_token": self.csrf, + }, + upload_files=[("json-file", "disqus.json", json_content)], + status=200, + ) + + self.assertIn(b"Successfully imported", res.body) + + # Verify user was created with automatic group postfix + user = get_user_by_email(self.dbsession, "testgroup@example.com") + self.assertIsNotNone(user) + # Should have postfix pattern in username (e.g., "Test-User-te") + self.assertIn("-", user.name) + + def test_import_empty_comments(self): + """Test import with threads that have no comments""" + disqus_data = { + "empty-post": { + "link": "https://example.com/empty-post", + "comments": [], + } + } + + json_content = json.dumps(disqus_data).encode("utf-8") + + res = self.testapp.post( + "/ns/test.example.com/import-comments", + { + "csrf_token": self.csrf, + }, + upload_files=[("json-file", "disqus.json", json_content)], + status=200, + ) + + self.assertIn(b"Successfully imported", res.body) + self.assertIn(b"0 threads", res.body) + self.assertIn(b"0 comments", res.body) + + def test_namespace_settings_links_to_import(self): + """Test that namespace settings page has a link to import page""" + res = self.testapp.get("/ns/test.example.com/settings", status=200) + self.assertIn(b"Import Comments", res.body) + self.assertIn(b"import-comments", res.body) + + def test_import_wordpress_format(self): + """Test import from WordPress XML export (blog-to-json format)""" + # Mock WordPress export data converted by wordpress-xml-to-json + wordpress_data = { + "homegrown-python-bread-crumb-module": { + "name": "a-homegrown-python-bread-crumb-module", + "title": "A homegrown python bread crumb module", + "timestamp": 1293995686, + "link": "http://russell.ballestrini.net/a-homegrown-python-bread-crumb-module/", + "date": "2011-01-02 14:14:46", + "content": "

Some content here

", + "comments": [ + { + "id": "wp-1", + "date": "2011-04-03 10:33:07", + "timestamp": 1301841187, + "content": "Hi, this was just what I needed", + "email": "kristian@example.com", + "author": "Kristian", + "author_ip": "192.168.1.5", + "parent_id": None, + }, + { + "id": "wp-2", + "date": "2011-04-03 14:19:46", + "timestamp": 1301854786, + "content": "I'm interested in the modifications", + "email": "russell@example.com", + "author": "Russell Ballestrini", + "author_ip": "192.168.1.6", + "parent_id": None, + } + ], + } + } + + json_content = json.dumps(wordpress_data).encode("utf-8") + + res = self.testapp.post( + "/ns/test.example.com/import-comments", + { + "csrf_token": self.csrf, + }, + upload_files=[("json-file", "wordpress.json", json_content)], + status=200, + ) + + self.assertIn(b"Successfully imported", res.body) + self.assertIn(b"1 threads", res.body) + self.assertIn(b"2 comments", res.body) + + # Verify users were created + kristian = get_user_by_email(self.dbsession, "kristian@example.com") + self.assertIsNotNone(kristian) + russell = get_user_by_email(self.dbsession, "russell@example.com") + self.assertIsNotNone(russell) + + def test_import_graphcomment_format(self): + """Test import from Graphcomment WordPress XML export (blog-to-json format)""" + # Mock Graphcomment export data converted by graphcomment-xml-to-json + graphcomment_data = { + "posts_jupyter-orgmode": { + "content": None, + "link": "https://abc.xyz/posts/jupyter-orgmode/", + "name": "posts_jupyter-orgmode", + "title": "Reflections on Jupyter", + "date": "2020-09-22 15:57:34", + "timestamp": 1600790254, + "id": "5f6a1eee2f57815d17188de2", + "metadata": {}, + "comments": [ + { + "id": "60750d2613ebd3704ec85f6f", + "content": "Thanks!!!!! a lot!!!", + "parent_id": None, + "author": "SamTux", + "date": "2021-04-13 03:16:54", + "timestamp": 1618283814, + "author_ip": "190.25.34.217", + "email": "samtux@example.com" + }, + { + "id": "60750d2613ebd3704ec85f70", + "content": "You're welcome!", + "parent_id": "60750d2613ebd3704ec85f6f", + "author": "BlogAuthor", + "date": "2021-04-14 10:20:00", + "timestamp": 1618395600, + "author_ip": "192.168.1.1", + "email": "author@example.com" + } + ], + } + } + + json_content = json.dumps(graphcomment_data).encode("utf-8") + + res = self.testapp.post( + "/ns/test.example.com/import-comments", + { + "csrf_token": self.csrf, + }, + upload_files=[("json-file", "graphcomment.json", json_content)], + status=200, + ) + + self.assertIn(b"Successfully imported", res.body) + self.assertIn(b"1 threads", res.body) + self.assertIn(b"2 comments", res.body) + + def test_import_with_deep_nesting(self): + """Test import with deeply nested comment hierarchy""" + nested_data = { + "nested-discussion": { + "link": "https://example.com/nested-discussion", + "title": "Nested Discussion", + "timestamp": 1234567890, + "comments": [ + { + "id": "1", + "author": "User1", + "email": "user1@example.com", + "content": "Top level comment", + "timestamp": 1234567890, + "parent_id": None, + "author_ip": "127.0.0.1", + }, + { + "id": "2", + "author": "User2", + "email": "user2@example.com", + "content": "Reply to comment 1", + "timestamp": 1234567900, + "parent_id": "1", + "author_ip": "127.0.0.2", + }, + { + "id": "3", + "author": "User3", + "email": "user3@example.com", + "content": "Reply to comment 2", + "timestamp": 1234567910, + "parent_id": "2", + "author_ip": "127.0.0.3", + }, + { + "id": "4", + "author": "User4", + "email": "user4@example.com", + "content": "Reply to comment 3", + "timestamp": 1234567920, + "parent_id": "3", + "author_ip": "127.0.0.4", + }, + ], + } + } + + json_content = json.dumps(nested_data).encode("utf-8") + + res = self.testapp.post( + "/ns/test.example.com/import-comments", + { + "csrf_token": self.csrf, + }, + upload_files=[("json-file", "nested.json", json_content)], + status=200, + ) + + self.assertIn(b"Successfully imported", res.body) + self.assertIn(b"1 threads", res.body) + self.assertIn(b"4 comments", res.body) + + def test_import_duplicate_prevention(self): + """Test that re-importing the same data reuses existing users""" + test_data = { + "test-duplicate": { + "link": "https://example.com/test-duplicate", + "comments": [ + { + "id": "dup-1", + "author": "DupUser", + "email": "dup@example.com", + "content": "First import", + "timestamp": 1234567890, + "parent_id": None, + "author_ip": "127.0.0.1", + }, + ], + } + } + + json_content = json.dumps(test_data).encode("utf-8") + + # First import + res1 = self.testapp.post( + "/ns/test.example.com/import-comments", + { + "csrf_token": self.csrf, + }, + upload_files=[("json-file", "dup.json", json_content)], + status=200, + ) + + self.assertIn(b"Successfully imported", res1.body) + self.assertIn(b"1 threads", res1.body) + self.assertIn(b"1 comments", res1.body) + + # Second import - should succeed and reuse existing user + res2 = self.testapp.post( + "/ns/test.example.com/import-comments", + { + "csrf_token": self.csrf, + }, + upload_files=[("json-file", "dup.json", json_content)], + status=200, + ) + + self.assertIn(b"Successfully imported", res2.body) + self.assertIn(b"1 threads", res2.body) + self.assertIn(b"1 comments", res2.body) + + # Verify only one user was created (this check works across transactions) + from remarkbox.models import User + user_count = self.dbsession.query(User).filter( + User.email == "dup@example.com" + ).count() + self.assertEqual(user_count, 1) + + def test_import_with_locked_group_postfix(self): + """Test that group postfix gets locked after first import""" + # Create a unique namespace for this test + from remarkbox.models import get_namespace_by_name + lock_test_ns = get_or_create_namespace(self.dbsession, "lock-test.example.com") + lock_test_ns.set_role_for_user(self.test_user, role="owner") + self.dbsession.add(lock_test_ns) + self.dbsession.flush() + self.tm.commit() + + test_data = { + "test-lock": { + "link": "https://example.com/test-lock", + "comments": [ + { + "id": "lock-1", + "author": "LockUser", + "email": "lock@example.com", + "content": "Test locking", + "timestamp": 1234567890, + "parent_id": None, + "author_ip": "127.0.0.1", + }, + ], + } + } + + json_content = json.dumps(test_data).encode("utf-8") + + # First import - postfix should be set + res1 = self.testapp.post( + "/ns/lock-test.example.com/import-comments", + { + "csrf_token": self.csrf, + "group-prefix": "mygrp", + }, + upload_files=[("json-file", "lock.json", json_content)], + status=200, + ) + + self.assertIn(b"Successfully imported", res1.body) + + # Reload namespace to check postfix was locked + namespace = get_namespace_by_name(self.dbsession, "lock-test.example.com") + self.assertEqual(namespace.import_group_postfix, "mygrp") + + # Second import should use locked postfix regardless of input + res2 = self.testapp.post( + "/ns/lock-test.example.com/import-comments", + { + "csrf_token": self.csrf, + "group-prefix": "different", # This should be ignored + }, + upload_files=[("json-file", "lock2.json", json_content)], + status=200, + ) + + # Verify postfix didn't change + namespace = get_namespace_by_name(self.dbsession, "lock-test.example.com") + self.assertEqual(namespace.import_group_postfix, "mygrp") + + def test_import_with_missing_email_creates_surrogates(self): + """Test that comments without email create unique surrogates""" + test_data = { + "test-surrogates": { + "link": "https://example.com/test-surrogates", + "comments": [ + { + "id": "s1", + "author": "Guest1", + "email": "", + "content": "First guest comment", + "timestamp": 1234567890, + "parent_id": None, + "author_ip": "127.0.0.1", + }, + { + "id": "s2", + "author": "Guest2", + "email": "", + "content": "Second guest comment", + "timestamp": 1234567900, + "parent_id": None, + "author_ip": "127.0.0.2", + }, + { + "id": "s3", + "author": "Guest1", # Same name as first + "email": "", + "content": "Another comment from Guest1", + "timestamp": 1234567910, + "parent_id": None, + "author_ip": "127.0.0.3", + }, + ], + } + } + + json_content = json.dumps(test_data).encode("utf-8") + + res = self.testapp.post( + "/ns/test.example.com/import-comments", + { + "csrf_token": self.csrf, + }, + upload_files=[("json-file", "surrogates.json", json_content)], + status=200, + ) + + self.assertIn(b"Successfully imported", res.body) + self.assertIn(b"3 comments", res.body) + + # Verify surrogates were created + from remarkbox.models import UserSurrogate + namespace = self.get_test_namespace() + surrogates = self.dbsession.query(UserSurrogate).filter( + UserSurrogate.namespace_id == namespace.id + ).all() + + # Should have 2 unique surrogates (Guest1 and Guest2) + # Guest1 appearing twice should reuse the same surrogate + self.assertEqual(len(surrogates), 2) + + # Verify surrogate names include group postfix + surrogate_names = [s.name for s in surrogates] + for name in surrogate_names: + self.assertIn("-", name) # Should have postfix + + def test_import_with_missing_fields(self): + """Test import handles missing optional fields gracefully""" + test_data = { + "test-missing-fields": { + "link": "https://example.com/test-missing", + "comments": [ + { + "id": "m1", + "author": "MinimalUser", + "email": "minimal@example.com", + "content": "Minimal comment", + "timestamp": 1234567890, + # Missing parent_id + # Missing author_ip + }, + ], + } + } + + json_content = json.dumps(test_data).encode("utf-8") + + res = self.testapp.post( + "/ns/test.example.com/import-comments", + { + "csrf_token": self.csrf, + }, + upload_files=[("json-file", "missing.json", json_content)], + status=200, + ) + + self.assertIn(b"Successfully imported", res.body) + self.assertIn(b"1 comments", res.body) + + def test_import_multiple_threads(self): + """Test importing multiple threads in one file""" + multi_thread_data = { + "thread-1": { + "link": "https://example.com/thread-1", + "comments": [ + { + "id": "t1-c1", + "author": "User1", + "email": "user1@example.com", + "content": "Comment on thread 1", + "timestamp": 1234567890, + "parent_id": None, + "author_ip": "127.0.0.1", + }, + ], + }, + "thread-2": { + "link": "https://example.com/thread-2", + "comments": [ + { + "id": "t2-c1", + "author": "User2", + "email": "user2@example.com", + "content": "Comment on thread 2", + "timestamp": 1234567900, + "parent_id": None, + "author_ip": "127.0.0.2", + }, + ], + }, + "thread-3": { + "link": "https://example.com/thread-3", + "comments": [ + { + "id": "t3-c1", + "author": "User3", + "email": "user3@example.com", + "content": "Comment on thread 3", + "timestamp": 1234567910, + "parent_id": None, + "author_ip": "127.0.0.3", + }, + ], + }, + } + + json_content = json.dumps(multi_thread_data).encode("utf-8") + + res = self.testapp.post( + "/ns/test.example.com/import-comments", + { + "csrf_token": self.csrf, + }, + upload_files=[("json-file", "multi.json", json_content)], + status=200, + ) + + self.assertIn(b"Successfully imported", res.body) + self.assertIn(b"3 threads", res.body) + self.assertIn(b"3 comments", res.body) + + def test_import_with_invalid_group_postfix(self): + """Test that invalid group postfix shows error""" + # Create a unique namespace for this test + invalid_test_ns = get_or_create_namespace(self.dbsession, "invalid-test.example.com") + invalid_test_ns.set_role_for_user(self.test_user, role="owner") + self.dbsession.add(invalid_test_ns) + self.dbsession.flush() + self.tm.commit() + + test_data = { + "test": { + "link": "https://example.com/test", + "comments": [ + { + "id": "1", + "author": "User", + "email": "user@example.com", + "content": "Test", + "timestamp": 1234567890, + "parent_id": None, + "author_ip": "127.0.0.1", + }, + ], + } + } + + json_content = json.dumps(test_data).encode("utf-8") + + # Try with too short postfix + res = self.testapp.post( + "/ns/invalid-test.example.com/import-comments", + { + "csrf_token": self.csrf, + "group-prefix": "x", # Only 1 char, minimum is 2 + }, + upload_files=[("json-file", "invalid.json", json_content)], + status=200, + ) + + self.assertIn(b"Group postfix is required and must be at least 2 alphanumeric characters", res.body) + + +class ImportCommentsUnitTests(unittest.TestCase): + """Unit tests for import_comments view functions""" + + def test_generate_password(self): + """Test password generation utility""" + from remarkbox.views.authenticated.import_comments import generate_password + + password = generate_password(32) + self.assertEqual(len(password), 32) + self.assertTrue(all(c.isalnum() for c in password)) + + def test_generate_password_custom_size(self): + """Test password generation with custom size""" + from remarkbox.views.authenticated.import_comments import generate_password + + password = generate_password(16) + self.assertEqual(len(password), 16) + + def test_generate_group_prefix_from_namespace(self): + """Test group prefix generation from namespace domain""" + from remarkbox.views.authenticated.import_comments import generate_group_prefix_from_namespace + + # Test multi-part domain + self.assertEqual(generate_group_prefix_from_namespace("russell.ballestrini.net"), "rb") + + # Test with www prefix + self.assertEqual(generate_group_prefix_from_namespace("www.example.com"), "exampl") + + # Test single-part domain + self.assertEqual(generate_group_prefix_from_namespace("example.com"), "exampl") + + # Test my.remarkbox.com style + prefix = generate_group_prefix_from_namespace("my.remarkbox.com") + self.assertTrue(len(prefix) <= 6) + self.assertTrue(prefix.isalnum()) diff --git a/remarkbox/views/authenticated/import_comments.py b/remarkbox/views/authenticated/import_comments.py new file mode 100644 index 0000000..a420d2f --- /dev/null +++ b/remarkbox/views/authenticated/import_comments.py @@ -0,0 +1,276 @@ +from pyramid.view import view_config +from pyramid.httpexceptions import HTTPFound +import json +import tempfile +import os +from datetime import datetime + +from remarkbox.models import ( + User, + UserSurrogate, + generate_user_name, + get_user_by_email, + get_user_surrogate_by_name, + get_or_create_node_by_uri, + is_user_name_valid, + is_user_name_available, +) + +from remarkbox.views import get_referer_or_home, user_required + +try: + unicode("") +except: + from six import u as unicode + + +def generate_password(size=32): + """Return a system generated password""" + from random import choice + letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + digits = "0123456789" + pool = letters + digits + return "".join([choice(pool) for i in range(size)]) + + +def generate_group_prefix_from_namespace(namespace_name): + """ + Generate a short, safe group prefix from namespace domain. + Takes first letters of domain parts, max 6 chars. + Example: russell.ballestrini.net -> rb, my.remarkbox.com -> mrc + """ + # Remove common TLDs and split on dots + domain = namespace_name.lower() + # Remove www prefix if present + if domain.startswith('www.'): + domain = domain[4:] + + # Split on dots and filter out common TLDs + parts = domain.split('.') + # Remove TLDs like com, org, net, io, etc + tlds = {'com', 'org', 'net', 'io', 'co', 'edu', 'gov', 'mil', 'int'} + parts = [p for p in parts if p not in tlds] + + if not parts: + # Use first 6 chars of full domain if no valid parts + prefix = ''.join(c for c in namespace_name if c.isalnum())[:6] + if len(prefix) < 2: + # Absolutely no valid characters, use hash of namespace + import hashlib + prefix = hashlib.md5(namespace_name.encode()).hexdigest()[:6] + return prefix + + # Strategy 1: Take first letter of each part (e.g., russell.ballestrini -> rb) + prefix = ''.join(p[0] for p in parts if p) + + # Strategy 2: If too short or just one part, take first few chars + if len(prefix) < 2 or len(parts) == 1: + prefix = parts[0][:6] + + # Ensure max 6 chars and only alphanumeric + prefix = ''.join(c for c in prefix if c.isalnum())[:6] + + # Final safety check - ensure at least 2 chars + if len(prefix) < 2: + # Use hash of namespace as last resort + import hashlib + prefix = hashlib.md5(namespace_name.encode()).hexdigest()[:6] + + return prefix + + + + +@view_config(route_name="basic-namespace-import-comments", renderer="import-comments.j2") +@view_config(route_name="embed-namespace-import-comments", renderer="import-comments.j2") +@user_required() +def import_comments(request): + """ + Import comments and threads from JSON dumps created by the blog-to-json tool. + Supports WordPress, Disqus, and pre-formatted JSON exports. + This view handles file upload and processes the JSON data. + """ + + if not request.user in request.namespace.owners: + request.session.flash(("You do not own that Namespace.", "error")) + return HTTPFound(get_referer_or_home(request)) + + # Check if namespace already has a locked group postfix + if request.namespace.import_group_postfix: + # Use the locked postfix + default_postfix = request.namespace.import_group_postfix + postfix_locked = True + else: + # Generate default group postfix from namespace + default_postfix = generate_group_prefix_from_namespace(request.namespace.name) + postfix_locked = False + + if request.method == "POST": + # Get the group postfix, allow user to override if not locked + if postfix_locked: + group = request.namespace.import_group_postfix + else: + group_postfix = request.params.get("group-prefix", "").strip() + if not group_postfix: + group_postfix = default_postfix + + # Sanitize: max 6 chars, alphanumeric only + group = ''.join(c for c in group_postfix if c.isalnum())[:6] + if not group or len(group) < 2: + request.session.flash(("Group postfix is required and must be at least 2 alphanumeric characters.", "error")) + return { + "the_title": "Import Comments", + "default_prefix": default_postfix, + "postfix_locked": postfix_locked, + } + + # Lock the group postfix in the namespace on first use + request.namespace.import_group_postfix = group + request.dbsession.add(request.namespace) + request.dbsession.flush() + + # Get the uploaded file + upload_file = request.params.get("json-file", None) + + if upload_file is None or upload_file == b'': + request.session.flash(("Please select a JSON file to upload.", "error")) + return {"the_title": "Import Comments"} + + try: + # Read the uploaded file + json_content = upload_file.file.read() + + # Parse JSON + try: + pages = json.loads(json_content) + except json.JSONDecodeError as e: + request.session.flash((f"Invalid JSON file: {str(e)}", "error")) + return {"the_title": "Import Comments"} + + # Process the import + users = {} + user_surrogates = {} + nodes = {} + imported_threads = 0 + imported_comments = 0 + + for slug, page_data in pages.items(): + comments = page_data.get("comments", []) + + if len(comments) == 0: + continue + + uri = page_data.get("link", "") + if not uri: + continue + + # Create the root node for this thread + root = get_or_create_node_by_uri(request.dbsession, uri) + root.namespace = request.namespace + request.dbsession.add(root) + request.dbsession.flush() + imported_threads += 1 + + for comment in comments: + email = comment.get("email", "") + + user = None + user_surrogate = None + + if not email: + # Always create surrogates for comments without email + # Use group postfix to make them identifiable + author_name = comment.get("author", "Anonymous") + # Create full name with group postfix (e.g., "Anonymous-rb-20231122-143045") + surrogate_name_with_group = f"{author_name}-{group}" + + # Check in-memory cache first + if surrogate_name_with_group not in user_surrogates: + # Check if this surrogate already exists in the database + existing_surrogate = get_user_surrogate_by_name( + request.dbsession, + surrogate_name_with_group, + root.namespace + ) + if existing_surrogate: + user_surrogates[surrogate_name_with_group] = existing_surrogate + else: + # Create new surrogate + user_surrogates[surrogate_name_with_group] = UserSurrogate( + surrogate_name_with_group, + root.namespace, + ) + request.dbsession.add(user_surrogates[surrogate_name_with_group]) + request.dbsession.flush() + + user_surrogate = user_surrogates[surrogate_name_with_group] + + elif email in users: + user = users[email] + else: + user = get_user_by_email(request.dbsession, email) + if user: + users[email] = user + else: + desired_name = ( + comment.get("author", "Anonymous").replace(" ", "-").replace("_", "-") + ) + # Always append group name to identify imported users + desired_name = desired_name + "-" + group + + if not is_user_name_valid( + desired_name + ) or not is_user_name_available( + request.dbsession, desired_name + ): + desired_name = generate_user_name( + request.dbsession, desired_name + ) + user = User(email) + user.name = unicode(desired_name) + users[email] = user + + request.dbsession.add(user) + request.dbsession.flush() + + # Create the comment node + node = root.new_child() + node.set_data(unicode(comment.get("content", ""))) + node.created = int(comment.get("timestamp", 0)) * 1000 + node.changed = int(comment.get("timestamp", 0)) * 1000 + + if user is not None: + node.user = user + elif user_surrogate is not None: + node.user_surrogate = user_surrogate + + node.verified = True + node.ip_address = comment.get("author_ip", None) + nodes[comment.get("id", "")] = node + + request.dbsession.add(node) + request.dbsession.flush() + imported_comments += 1 + + # Set up parent-child relationships + for comment in comments: + parent_id = comment.get("parent_id") + comment_id = comment.get("id", "") + if parent_id and nodes.get(comment_id) and nodes.get(parent_id): + nodes[comment_id].parent_id = nodes[parent_id].id + request.dbsession.add(nodes[comment_id]) + request.dbsession.flush() + + request.session.flash(( + f"Successfully imported {imported_threads} threads and {imported_comments} comments.", + "success" + )) + + except Exception as e: + request.session.flash((f"Error during import: {str(e)}", "error")) + + return { + "the_title": "Import Comments", + "default_prefix": default_postfix, + "postfix_locked": postfix_locked, + }